From 9279384e9a76d00b5d7403f4fcc8f16537b0444d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:31:30 +0300 Subject: [PATCH] Complete PRESSPULL selection, face editing and extrusion history --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 60 +---- src/app/commands/display.rs | 16 +- src/app/mod.rs | 1 + src/app/model_ops.rs | 61 +---- src/app/presspull_ops.rs | 244 +++++++++++++++++++ src/app/properties.rs | 7 +- src/app/update/viewport.rs | 148 +++++++++++- src/command.rs | 39 ++- src/modules/insert/solid3d_cmds.rs | 344 ++++++++++++++++++++++---- src/scene/mod.rs | 19 +- src/scene/model/mod.rs | 1 + src/scene/model/presspull_model.rs | 376 +++++++++++++++++++++++++++++ src/scene/model/solid_history.rs | 97 +++++--- src/scene/modify.rs | 114 +++++++-- 16 files changed, 1284 insertions(+), 247 deletions(-) create mode 100644 src/app/presspull_ops.rs create mode 100644 src/scene/model/presspull_model.rs diff --git a/Cargo.lock b/Cargo.lock index bdec330b9..ea8fe4f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5565ec3f23f6629974d2a5fed5215d2b92738a58#5565ec3f23f6629974d2a5fed5215d2b92738a58" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=37a33babe7d64e05d9d4144981eeb9bc8ebf390e#37a33babe7d64e05d9d4144981eeb9bc8ebf390e" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 60315f9b5..b77d57a61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "580678d28f11ccbefbce3f9d3390d28ef4bbde80", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5565ec3f23f6629974d2a5fed5215d2b92738a58", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "37a33babe7d64e05d9d4144981eeb9bc8ebf390e", 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/src/app/command_driver.rs b/src/app/command_driver.rs index 5327b777a..992158d88 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3792,61 +3792,11 @@ impl OpenCADStudio { self.refresh_properties(); } - CmdResult::PresspullEntity { - handle, - pick, - distance, - drag, - color, - } => { - if matches!( - self.tabs[i].scene.document.get_entity(handle), - Some(acadrust::EntityType::Solid3D(_)) - ) { - let task = self.solid_face_presspull(handle, pick, distance, drag); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - return task; - } - if self.reject_locked_edit(i, handle) { - self.tabs[i].active_cmd = None; - return Task::none(); - } - use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_history, sweep_model}; - - let result = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { - let distance = match drag { - Some(point) => sweep_model::projected_drag(entity, pick, point)?, - None => distance, - }; - sweep_model::extruded(entity, distance) - }); - if let Some(solid) = result { - let pending = self.begin_undo(i, "PRESSPULL", 1, true); - let history = solid_history::brep_op(&solid); - let created = self.add_solid_model(empty_solid3d(), solid, history); - let _ = color; - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("PRESSPULL: solid created.").as_ref()); - if let Some(delta) = pending { - self.commit_undo_delta(i, delta); - } - } - } else { - self.command_line.push_error( - crate::t!("PRESSPULL: select a closed profile or planar solid face.") - .as_ref(), - ); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); + CmdResult::PresspullPick { handle, point, offset, multiple: _ } => { + self.presspull_pick(handle, point, offset); + } + CmdResult::PresspullApply { targets, distance, color: _ } => { + self.presspull_apply(targets, distance); } // ── REVOLVE ──────────────────────────────────────────────────── diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index cc32048c2..0b5ec6aa7 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -771,22 +771,10 @@ impl OpenCADStudio { "PRESSPULL" => { use crate::modules::insert::solid3d_cmds::PresspullCommand; - let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let mut command = PresspullCommand::new(color); - if selected.len() == 1 - && !matches!(selected[0].1, acadrust::EntityType::Solid3D(_)) - { - if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { - command.set_entity_pick_direction( - curve.plane.normal().map(glam::DVec3::from_array), - ); - command.on_entity_pick( - selected[0].0, - glam::DVec3::from_array(curve.plane.origin), - ); - } - } + command.set_isolines(self.tabs[i].scene.document.header.isolines.max(0) as usize); + command.set_preselection(self.presspull_preselection()); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/mod.rs b/src/app/mod.rs index a8c407776..3053b067f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; mod properties; +mod presspull_ops; mod recent; pub(crate) mod settings; mod shortcuts; diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index b1ea73dc7..38c287d23 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -119,6 +119,12 @@ impl super::OpenCADStudio { entity.history_handle = None; entity.set_sat_document(&document); + let Some(display) = self.tabs[i].scene.prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + return false; + }; self.push_undo_snapshot(i, label); self.tabs[i].scene.delete_solid_history(handle); if !self.tabs[i] @@ -131,7 +137,7 @@ impl super::OpenCADStudio { } let history = solid_history::brep_op(&result); self.tabs[i].scene.create_solid_history(handle, history); - self.tabs[i].scene.register_solid_model(handle, result); + self.tabs[i].scene.register_prepared_solid_model(handle, result, display); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(handle, false); self.tabs[i].dirty = true; @@ -189,59 +195,6 @@ impl super::OpenCADStudio { Task::none() } - pub(super) fn solid_face_presspull( - &mut self, - handle: Handle, - pick: glam::DVec3, - distance: f64, - drag: Option, - ) -> Task { - let i = self.active_tab; - if self.reject_locked_edit(i, handle) { - return Task::none(); - } - self.tabs[i].scene.restore_solid_models(&[handle]); - let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { - self.command_line - .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); - return Task::none(); - }; - let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else { - self.command_line - .push_error(crate::t!("Select a planar solid face.").as_ref()); - return Task::none(); - }; - let distance = match drag { - Some(point) => { - let Some(normal) = solid_model::planar_face_normal(&body, face) else { - self.command_line - .push_error(crate::t!("Select a planar solid face.").as_ref()); - return Task::none(); - }; - (point - pick).dot(glam::DVec3::from_array(normal)) - } - None => distance, - }; - if !distance.is_finite() || distance.abs() <= 1e-6 { - self.command_line.push_error( - crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(), - ); - return Task::none(); - } - let Some(result) = cadkernel::brep::presspull(&body, face, distance) else { - self.command_line.push_error( - crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.") - .as_ref(), - ); - return Task::none(); - }; - if self.replace_solid_body(handle, result, "PRESSPULL") { - self.command_line - .push_output(crate::t!("PRESSPULL: solid updated.").as_ref()); - } - Task::none() - } - /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { let i = self.active_tab; diff --git a/src/app/presspull_ops.rs b/src/app/presspull_ops.rs new file mode 100644 index 000000000..e9e729946 --- /dev/null +++ b/src/app/presspull_ops.rs @@ -0,0 +1,244 @@ +//! PRESSPULL scene integration. Resolve first; build and validate the complete +//! batch before recording undo or changing any persistent entity. + +use acadrust::entities::{AcisData, EmbeddedEntity, Solid3D, Surface, SurfaceKind, Wire}; +use acadrust::objects::{DynamicBlockData, ObjectType, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistorySweep}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use cadkernel::brep::{self, Body, PlanarFaceProfile, PresspullMode}; +use glam::DVec3; +use crate::scene::model::{solid_history, sweep_model}; +use crate::scene::model::mesh_model::MeshLodSet; +use crate::scene::model::presspull_model::{self, PresspullTarget, PresspullTargetKind}; + +struct PreparedPresspull { + owner: Option, + entity: EntityType, + body: Body, + history: SolidHistoryOperation, + display: (MeshLodSet, Vec, [f64; 3]), +} + +fn extrusion_history(entity: &EntityType, direction: DVec3, anchor: DVec3) -> Option { + if let EntityType::Region(region) = entity { + let mut base = SolidHistoryNodeBase::new(1); + base.transform = glam::DMat4::IDENTITY.to_cols_array(); + return Some(SolidHistoryOperation::Extrusion(SolidHistorySweep { + base, + operation_major: 1, + sweep_entity: Some(EmbeddedEntity::Region(region.clone())), + direction: Vector3::new(direction.x, direction.y, direction.z), + end_draft_distance: direction.length(), + scale_factor: 1.0, + sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), + reference_point: Vector3::new(anchor.x, anchor.y, anchor.z), + ..SolidHistorySweep::default() + })); + } + sweep_model::extrusion_history(entity, None, direction.to_array(), 0.0, anchor.to_array()) +} + +impl super::OpenCADStudio { + fn restore_presspull_bodies(&mut self) { + let scene = &mut self.tabs[self.active_tab].scene; + let handles = scene.document.entities().filter_map(|entity| { + matches!(entity, EntityType::Solid3D(_)).then_some(entity.common().handle) + }).collect::>(); + scene.restore_solid_models(&handles); + } + + pub(super) fn presspull_preselection(&mut self) -> Vec { + self.restore_presspull_bodies(); + let tab = &self.tabs[self.active_tab]; + let plane = tab.ucs_xform().working_plane(); + tab.scene.selected_handles_in_order().into_iter().filter_map(|handle| { + let entity = tab.scene.document.get_entity(handle)?; + let (profile_plane, loops, _) = presspull_model::profile_geometry(entity)?; + let first = loops.first()?.first()?.point_at(0.0); + let anchor = DVec3::from_array(profile_plane.point_at(first)); + presspull_model::resolve_target(&tab.scene, Some(handle), anchor, plane, false).ok() + }).collect() + } + + pub(super) fn presspull_pick(&mut self, handle: Option, point: DVec3, offset: bool) { + self.restore_presspull_bodies(); + let i = self.active_tab; + let target = presspull_model::resolve_target( + &self.tabs[i].scene, handle, point, self.tabs[i].ucs_xform().working_plane(), offset, + ); + match target { + Ok(target) => { + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_target(target); + } + self.tabs[i].scene.set_hover_highlight(None); + } + Err(error) => self.command_line.push_error(&error), + } + self.refresh_presspull_prompt(); + } + + fn refresh_presspull_prompt(&mut self) { + if let Some(command) = self.tabs[self.active_tab].active_cmd.as_ref() { + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + } + self.sync_dyn_fields(); + } + + fn prepare_presspull(&self, targets: &[PresspullTarget], distance: f64) -> Result, String> { + if targets.is_empty() || !distance.is_finite() || distance.abs() <= 1e-9 { + return Err("PRESSPULL: enter a finite non-zero height.".into()); + } + let scene = &self.tabs[self.active_tab].scene; + let mut results: Vec<(Option, EntityType, Body, SolidHistoryOperation)> = Vec::new(); + for target in targets { + let owner = match &target.kind { + PresspullTargetKind::Profile { source, entity, owner } => { + if let Some(source) = source { + if scene.is_layer_locked(*source) || scene.document.get_entity(*source) != Some(entity) { + return Err("PRESSPULL: a selected source changed. Select it again.".into()); + } + } + *owner + } + PresspullTargetKind::Face { handle, .. } => Some(*handle), + }; + if owner.is_some_and(|handle| scene.is_layer_locked(handle)) { + return Err("PRESSPULL: the solid is on a locked layer.".into()); + } + let previous = owner.and_then(|handle| results.iter().position(|result| result.0 == Some(handle))); + let current = previous.map(|index| &results[index].2) + .or_else(|| owner.and_then(|handle| scene.solid_models.get(&handle))); + let (body, history, mut entity) = match &target.kind { + PresspullTargetKind::Profile { entity, .. } => { + let (plane, loops, closed) = presspull_model::profile_geometry(entity) + .ok_or("PRESSPULL: the profile could not be recovered exactly.")?; + if owner.is_some() { + let current = current.ok_or("PRESSPULL: the original solid could not be restored.")?; + if !closed { + return Err("PRESSPULL: an attached region must be closed.".into()); + } + let profile = PlanarFaceProfile { plane, loops, outward: target.direction.to_array() }; + let body = brep::presspull_region(current, &profile, distance) + .ok_or("PRESSPULL: the region could not be added to or cut from the solid.")?; + let history = solid_history::brep_op(&body); + (body, history, scene.document.get_entity(owner.unwrap()).cloned() + .ok_or("PRESSPULL: the original solid no longer exists.")?) + } else { + let direction = target.direction * distance; + let body = presspull_model::extrusion_body(entity, direction.to_array()) + .ok_or("PRESSPULL: the profile could not be extruded with this height.")?; + let history = extrusion_history(entity, direction, target.anchor) + .ok_or("PRESSPULL: the editable extrusion profile could not be retained.")?; + let entity = if closed { + EntityType::Solid3D(Solid3D::new()) + } else { + let mut surface = Surface::new(SurfaceKind::Extruded); + if let SolidHistoryOperation::Extrusion(record) = &history { + surface.surface_data = solid_history::extrusion_surface_data(record) + .ok_or("PRESSPULL: the surface parameters could not be retained.")?; + } + EntityType::Surface(surface) + }; + (body, history, entity) + } + } + PresspullTargetKind::Face { face, offset, .. } => { + let current = current.ok_or("PRESSPULL: the original solid could not be restored.")?; + // Earlier targets in the same batch may have rebuilt face keys. + let face = if previous.is_some() { + brep::planar_face_at_point(current, target.anchor.to_array(), 1e-6) + .ok_or("PRESSPULL: an earlier selected region changed this face.")? + } else { *face }; + let body = brep::presspull_face(current, face, distance, + if *offset { PresspullMode::Offset } else { PresspullMode::Extrude }) + .ok_or("PRESSPULL: the requested face operation would invalidate the solid.")?; + let history = solid_history::brep_op(&body); + (body, history, scene.document.get_entity(owner.unwrap()).cloned() + .ok_or("PRESSPULL: the original solid no longer exists.")?) + } + }; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("PRESSPULL: the result could not be encoded. The original objects were retained.")?; + if owner.is_none() { entity.common_mut().plotstyle_flags = 2; } + match &mut entity { + EntityType::Solid3D(solid) => { + solid.history_handle = None; + solid.silhouettes.clear(); + solid.set_sat_document(&sat); + } + EntityType::Surface(surface) => { + surface.history_handle = None; + surface.silhouettes.clear(); + surface.acis_data = AcisData::from_sat(&sat.to_sat_string()); + } + _ => return Err("PRESSPULL: unsupported result object.".into()), + } + let result = (owner, entity, body, history); + if let Some(index) = previous { results[index] = result; } else { results.push(result); } + } + results.into_iter().map(|(owner, entity, body, history)| { + let display = scene.prepare_solid_model_display(owner.unwrap_or(Handle::NULL), &body) + .filter(|display| display.0.complete && display.0.lods.iter().any(|mesh| !mesh.indices.is_empty())) + .ok_or("PRESSPULL: the complete result could not be displayed. The original objects were retained.")?; + Ok(PreparedPresspull { owner, entity, body, history, display }) + }).collect() + } + + pub(super) fn presspull_apply(&mut self, targets: Vec, distance: f64) { + let i = self.active_tab; + let prepared = match self.prepare_presspull(&targets, distance) { + Ok(prepared) => prepared, + Err(error) => { + self.command_line.push_error(&error); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_applied(false); + } + self.refresh_presspull_prompt(); + return; + } + }; + let pending = self.begin_undo(i, "PRESSPULL", prepared.len(), true); + let mut handles = Vec::new(); + // All fallible geometry, persistence and display work finished above. + // No source curve is deleted by PRESSPULL. + for result in prepared { + let handle = if let Some(handle) = result.owner { + self.tabs[i].scene.delete_solid_history(handle); + self.tabs[i].scene.update_entity(result.entity); + handle + } else { + self.commit_entity_handle(result.entity).expect("validated solid/surface insertion") + }; + self.tabs[i].scene.create_solid_history(handle, result.history); + // PRESSPULL keeps editable construction parameters without enabling + // recording of subsequent solid operations by default. + if result.owner.is_none() { + let document = &mut self.tabs[i].scene.document; + if let Some(graph) = document.solid_history_graph(handle) { + if let Some(ObjectType::DynamicBlock(object)) = document.objects.get_mut(&graph.root) { + if let DynamicBlockData::SolidHistory(history) = &mut object.data { + history.record_history = false; + } + } + } + } + self.tabs[i].scene.register_prepared_solid_model(handle, result.body, result.display); + handles.push(handle); + } + self.tabs[i].scene.deselect_all(); + for handle in &handles { self.tabs[i].scene.select_entity(*handle, false); } + self.tabs[i].dirty = true; + if let Some(pending) = pending { self.commit_undo_delta(i, pending); } + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].snap_result = None; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.on_presspull_applied(true); + } + self.command_line.push_output(crate::tf!("PRESSPULL: %{count} object(s) updated.", count = handles.len()).as_ref()); + self.refresh_properties(); + self.refresh_presspull_prompt(); + } +} diff --git a/src/app/properties.rs b/src/app/properties.rs index a08166252..8a02a4fff 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -448,12 +448,7 @@ impl OpenCADStudio { let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); if specialized_primitive { - sections.retain(|section| { - !section.props.iter().any(|property| { - property.field.starts_with("acis_") - || property.field.starts_with("s3d_") - }) - }); + retain_specialized_primitive_sections(&mut sections); } sections.extend( crate::scene::model::solid_history::primitive_properties( diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 27126d77c..3474330de 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -494,6 +494,25 @@ impl OpenCADStudio { } } + /// An acquired planar profile can lie away from the current UCS plane. + /// Reproject the cursor onto that profile before using it as a drag anchor + /// or looking for its supporting solid face. + fn profile_pick_point( + &self, i: usize, handle: Handle, + edit_cam: &Option, + cursor: iced::Point, bounds: iced::Rectangle, + ) -> Option { + let entity = self.tabs[i].scene.document.get_entity(handle)?; + let (plane, _, _) = crate::scene::model::presspull_model::profile_geometry(entity)?; + let normal = glam::DVec3::from_array(plane.normal()?).as_vec3(); + let origin = glam::DVec3::from_array(plane.origin); + let point = match edit_cam { + Some(camera) => camera.pick_on_plane(cursor, bounds, normal, origin), + None => self.tabs[i].scene.camera.borrow().pick_on_plane(cursor, bounds, normal, origin), + }; + point.is_finite().then_some(point) + } + /// Projection + hit-test wires for the active pane. Inside a floating /// viewport (`edit_cam` Some) it returns the viewport camera and the live /// **model** wires, so wire / hatch picking lands on the entity under the @@ -922,6 +941,12 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + // Modifier-driven selection must be known before cursor_plane/axis and + // drafting constraints are read, not merely before the final callback. + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } let perf_move = crate::perf::enabled(); let move_started = Instant::now(); @@ -1765,7 +1790,9 @@ impl OpenCADStudio { // O(N) per frame and stalls the cursor on large drawings, // so each move resets the dwell timer — `HoverDwellTick` runs the hit-test only // once the cursor has been still for `HOVER_DWELL_MS`. - if !dragging && self.tabs[i].active_cmd.is_none() { + let deferred_command_hover = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); + if !dragging && (self.tabs[i].active_cmd.is_none() || deferred_command_hover) { // On dense drawings, clearing a rollover immediately schedules a // second full scene frame just as motion resumes. Keep the previous // highlight until the next settled pick replaces it. @@ -2090,21 +2117,25 @@ impl OpenCADStudio { } pt }; - let effective = self.tabs[i] + let effective = if needs_entity && self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + cursor_world + } else { self.tabs[i] .active_cmd .as_ref() .and_then(|command| command.cursor_axis()) .and_then(|(origin, direction)| { cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) }) - .unwrap_or(effective); + .unwrap_or(effective) }; // Dynamic-input locked fields constrain the preview point // (#356): a typed angle pins the direction, a typed // distance pins the radius — the same resolution the Enter // commit uses, so preview and commit agree. The lock wins // over osnap/ortho/polar. let effective = { - let locked = self.tabs[i].active_cmd.is_some() + let locked = !needs_entity && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()); if locked { self.tabs[i].last_cursor_world = effective; @@ -2224,6 +2255,10 @@ impl OpenCADStudio { p.extend(cmd.on_preview_wires(effective)); } p + } else if needs_entity && deferred_command_hover { + // The dwell callback resolves and outlines bounded areas once. + // Moving the cursor clears that outline without rebuilding it. + Vec::new() } else if needs_entity { let include_fills = self.tabs[i] .active_cmd @@ -2459,6 +2494,12 @@ impl OpenCADStudio { pub(crate) fn on_viewport_exit(&mut self) -> Task { let i = self.active_tab; + self.hover_dwell = None; + if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + { + self.tabs[i].scene.clear_preview_wire(); + } let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.left_down = false; sel.left_press_pos = None; @@ -2795,6 +2836,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } // A left-click during a command resets the right-click cycle, so // the next right-click acts as Enter again rather than opening // the context menu. @@ -3074,6 +3119,10 @@ impl OpenCADStudio { pub(super) fn on_viewport_left_release(&mut self) -> Task { let i = self.active_tab; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } // Navigation mode: end this drag but keep the tool armed for the next // left drag (exit is Esc / another command). Mirror of the press. @@ -3493,7 +3542,7 @@ impl OpenCADStudio { // A click while dynamic-input fields hold typed values // commits the CONSTRAINED point — the same resolution // the preview shows and Enter would commit (#356). - if self.tabs[i].active_cmd.is_some() + if !needs_entity_click && self.tabs[i].active_cmd.is_some() && self.tabs[i].dyn_fields.iter().any(|f| f.buffer.is_some()) { self.tabs[i].last_cursor_world = pt; @@ -3501,7 +3550,13 @@ impl OpenCADStudio { pt = r; } } - pt + if needs_entity_click && self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + raw + } else { + pt + } }; // `world_pt` is in offset-relative (local) space, matching @@ -3520,6 +3575,10 @@ impl OpenCADStudio { world_pt + glam::DVec3::new(wo[0], wo[1], wo[2]) }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } let result = if self.tabs[i] .active_cmd .as_ref() @@ -3631,11 +3690,26 @@ impl OpenCADStudio { self.tabs[i] .scene .solid_click_point_for(p, view_rot2, eye2, bounds, handle) - .unwrap_or(pick_wcs) + .or_else(|| self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()) + .then(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)).flatten()) + .unwrap_or_else(|| { + let bounded_pick = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_deferred_hover()); + if bounded_pick && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)), + ) { + // The aperture caught an edge outside its face. + // Do not reinterpret the working-plane point as + // a hit on another cap of the same body. + glam::DVec3::NAN + } else { pick_wcs } + }) } else { pick_wcs }; - let entity_pick_direction = if uses_surface_point { + let entity_pick_direction = if uses_surface_point && entity_pick_point.is_finite() { if matches!( self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)) @@ -3749,6 +3823,10 @@ impl OpenCADStudio { } } result + } else if self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()) + { + self.tabs[i].active_cmd.as_mut().map(|command| command.on_point(pick_wcs)) } else if self.tabs[i] .active_cmd .as_ref() @@ -4771,7 +4849,9 @@ impl OpenCADStudio { /// ends. Repeated wheel events replace this timestamp, so the pick runs only /// after the final zoom step settles. pub(in crate::app) fn arm_hover_after_navigation(&mut self, i: usize) { - if i >= self.tabs.len() || self.tabs[i].active_cmd.is_some() { + if i >= self.tabs.len() || self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| !command.entity_pick_deferred_hover()) + { return; } let (cursor, canvas_size) = { @@ -5062,12 +5142,19 @@ impl OpenCADStudio { let i = dwell.tab; // Re-check the gate — drag / command may have started // between the move that armed the dwell and this tick. - if i >= self.tabs.len() { + if i >= self.tabs.len() || i != self.active_tab { self.hover_dwell = None; return Task::none(); } + self.push_ucs_to_cmd(i); + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_ctrl(self.ctrl_down); + command.set_shift(self.shift_down); + } + let deferred_command = self.tabs[i].active_cmd.as_ref() + .is_some_and(|command| command.needs_entity_pick() && command.entity_pick_deferred_hover()); let navigating = self.tabs[i].scene.selection.borrow().middle_down; - if self.tabs[i].active_cmd.is_some() || navigating { + if (self.tabs[i].active_cmd.is_some() && !deferred_command) || navigating { self.clear_navigation_hover(i); return Task::none(); } @@ -5158,16 +5245,51 @@ impl OpenCADStudio { } if hovered.is_none() { let started = Instant::now(); - hovered = self.tabs[i].scene.solid_edge_hover_hit( + hovered = if deferred_command { self.tabs[i].scene.solid_click_hit( + p, view_rot, eye, bounds, candidate_handles.as_ref(), + ) } else { self.tabs[i].scene.solid_edge_hover_hit( p, view_rot, eye, bounds, candidate_handles.as_ref(), crate::ui::overlay::pick_box_aperture_px(self.pick_box), - ); + ) }; solid_ms = started.elapsed().as_secs_f64() * 1000.0; } + if deferred_command { + let surface_point = hovered.and_then(|handle| { + self.tabs[i].scene.solid_click_point_for(p, view_rot, eye, bounds, handle) + .or_else(|| self.profile_pick_point(i, handle, &edit_cam, p, bounds)) + }); + let point = surface_point.unwrap_or(hover_world); + let is_solid = hovered.is_some_and(|handle| matches!( + self.tabs[i].scene.document.get_entity(handle), Some(acadrust::EntityType::Solid3D(_)), + )); + // An aperture may catch an edge beside, rather than on, a face. + // Never send its XY-plane fallback as a 3D face pick. + if is_solid && surface_point.is_none() { + self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.clear_preview_wire(); + self.hover_dwell = None; + return Task::none(); + } + let handles = self.tabs[i].scene.document.entities().filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }).collect::>(); + self.tabs[i].scene.restore_solid_models(&handles); + let show = (self.model_space.selection_preview & 2) != 0; + let previews = if show { + let tab = &mut self.tabs[i]; + tab.active_cmd.as_mut().map(|command| { + command.on_deferred_entity_hover(&tab.scene, hovered, point) + }).unwrap_or_default() + } else { Vec::new() }; + self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_preview_wires(previews); + self.hover_dwell = None; + return Task::none(); + } let preview_idle = (self.model_space.selection_preview & 1) != 0; if preview_idle { self.tabs[i].scene.set_hover_highlight(hovered); diff --git a/src/command.rs b/src/command.rs index 4a7e983cc..12222ac40 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1507,12 +1507,17 @@ pub enum CmdResult { taper_angle: f64, color: [f32; 4], }, - /// Pull a closed profile or a planar solid face by a signed distance. - PresspullEntity { - handle: Handle, - pick: DVec3, + /// Resolve a profile, bounded area, or solid face for PRESSPULL. + PresspullPick { + handle: Option, + point: DVec3, + offset: bool, + multiple: bool, + }, + /// Apply a signed distance to the resolved PRESSPULL selection. + PresspullApply { + targets: Vec, distance: f64, - drag: Option, color: [f32; 4], }, /// Revolve the profile entities around the given axis. @@ -1937,6 +1942,19 @@ pub trait CadCommand: Send { false } + /// Expensive bounded-area acquisition runs once after cursor dwell, not + /// from every pointer event. The host supplies the same WCS surface pick + /// used for clicks, and clears the overlay when movement resumes. + fn entity_pick_deferred_hover(&self) -> bool { + false + } + + fn on_deferred_entity_hover( + &mut self, _scene: &Scene, _handle: Option, _point: DVec3, + ) -> Vec { + Vec::new() + } + /// Called when the text editor closes, either because the user committed or cancelled the edit. fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { CmdResult::Cancel @@ -1953,6 +1971,17 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Supply a resolved PRESSPULL target after an object or bounded-area pick. + fn on_presspull_target( + &mut self, + _target: crate::scene::model::presspull_model::PresspullTarget, + ) { + } + + /// Resume PRESSPULL after an atomic apply attempt. A failed operation keeps + /// the current targets available for another distance or selection undo. + fn on_presspull_applied(&mut self, _success: bool) {} + /// Host callback after `CmdResult::CommitLiveEntity`: records the handle the /// new live entity was assigned so later `UpdateLiveEntity` results can /// target it. diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 1d460c714..26e076108 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -10,6 +10,7 @@ use crate::command::{ LoftSectionSelection, SelectionEntity, SweepOptions, WorkingPlane, }; use crate::scene::WireModel; +use crate::scene::model::presspull_model::{PresspullTarget, PresspullTargetKind}; use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── @@ -515,19 +516,125 @@ impl CadCommand for ExtrudeCommand { // ── PRESSPULL command ───────────────────────────────────────────────────── pub struct PresspullCommand { - picked: Option<(acadrust::Handle, DVec3)>, - direction: Option, + step: PresspullStep, + targets: Vec, + ctrl: bool, + shift: bool, + isolines: usize, + target_generation: u64, + preview_key: Option<(u64, u64)>, + preview_cache: Vec, + working_plane: WorkingPlane, + hover_key: Option<(u64, Option, [u64; 3], bool)>, + hover_cache: Vec, color: [f32; 4], } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PresspullStep { + Pick, + Multiple, + Height, +} + impl PresspullCommand { pub fn new(color: [f32; 4]) -> Self { Self { - picked: None, - direction: None, + step: PresspullStep::Pick, + targets: Vec::new(), + ctrl: false, + shift: false, + isolines: 0, + target_generation: 0, + preview_key: None, + preview_cache: Vec::new(), + working_plane: WorkingPlane::default(), + hover_key: None, + hover_cache: Vec::new(), color, } } + + pub fn set_isolines(&mut self, isolines: usize) { + if self.isolines != isolines { + self.isolines = isolines; + self.invalidate_preview(); + } + } + + pub fn set_preselection(&mut self, targets: Vec) { + self.targets.clear(); + self.step = PresspullStep::Pick; + self.invalidate_preview(); + for target in targets { + self.on_presspull_target(target); + } + } + + fn invalidate_preview(&mut self) { + self.target_generation = self.target_generation.wrapping_add(1); + self.preview_key = None; + self.preview_cache.clear(); + self.hover_key = None; + self.hover_cache.clear(); + } + + fn has_target(&self, target: &PresspullTarget) -> bool { + self.targets.iter().any(|selected| match (&selected.kind, &target.kind) { + ( + PresspullTargetKind::Profile { source: Some(first), .. }, + PresspullTargetKind::Profile { source: Some(second), .. }, + ) => first == second, + ( + PresspullTargetKind::Face { handle: first, face: first_face, .. }, + PresspullTargetKind::Face { handle: second, face: second_face, .. }, + ) => first == second && first_face == second_face, + ( + PresspullTargetKind::Profile { source: None, entity: first, owner: first_owner }, + PresspullTargetKind::Profile { source: None, entity: second, owner: second_owner }, + ) => first_owner == second_owner && first == second, + _ => false, + }) + } + + fn pick(&self, handle: Option, point: DVec3) -> CmdResult { + if !point.is_finite() { + return CmdResult::NeedPoint; + } + CmdResult::PresspullPick { + handle: handle.filter(|handle| !handle.is_null()), + point, + offset: self.ctrl, + multiple: self.step == PresspullStep::Multiple || self.shift, + } + } + + fn height(&self, point: DVec3) -> Option { + let target = self.targets.last()?; + let distance = (point - target.anchor).dot(target.direction); + distance.is_finite().then_some(distance) + } + + fn finish(&self, distance: f64) -> CmdResult { + if self.targets.is_empty() || !distance.is_finite() || distance.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + CmdResult::PresspullApply { + targets: self.targets.clone(), + distance, + color: self.color, + } + } + + fn undo_selection(&mut self) -> CmdResult { + if self.targets.pop().is_some() { + self.invalidate_preview(); + } + if self.targets.is_empty() { + self.step = PresspullStep::Pick; + } + CmdResult::NeedPoint + } } impl CadCommand for PresspullCommand { @@ -536,15 +643,37 @@ impl CadCommand for PresspullCommand { } fn prompt(&self) -> String { - if self.picked.is_none() { - t!("PRESSPULL Select a closed profile or planar solid face:").into_owned() - } else { - t!("PRESSPULL Signed distance:").into_owned() + match self.step { + PresspullStep::Pick => { + t!("PRESSPULL Select object or bounded area (Enter to finish):").into_owned() + } + PresspullStep::Multiple => { + t!("PRESSPULL Select additional objects or bounded areas [Undo] (Enter for height):").into_owned() + } + PresspullStep::Height => { + t!("PRESSPULL Specify signed extrusion height or [Multiple/Undo]:").into_owned() + } + } + } + + fn options(&self) -> Vec { + match self.step { + PresspullStep::Pick => vec![CmdOption::enter("Done")], + PresspullStep::Multiple => { + vec![CmdOption::new("Undo", "UNDO"), CmdOption::enter("Height")] + } + PresspullStep::Height => { + vec![CmdOption::new("Multiple", "MULTIPLE"), CmdOption::new("Undo", "UNDO")] + } } } fn needs_entity_pick(&self) -> bool { - self.picked.is_none() + self.step != PresspullStep::Height || self.shift + } + + fn entity_pick_accepts_points(&self) -> bool { + true } fn entity_pick_includes_fills(&self) -> bool { @@ -555,69 +684,166 @@ impl CadCommand for PresspullCommand { true } - fn set_entity_pick_direction(&mut self, direction: Option) { - self.direction = direction.and_then(|value| value.try_normalize()); + fn set_ctrl(&mut self, ctrl: bool) { + self.ctrl = ctrl; + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + if self.working_plane.origin != plane.origin || self.working_plane.x != plane.x + || self.working_plane.y != plane.y + { + self.hover_key = None; + self.hover_cache.clear(); + } + self.working_plane = plane; + } + + fn set_shift(&mut self, shift: bool) { + self.shift = shift; } fn entity_pick_highlights_hover(&self) -> bool { true } + fn entity_pick_deferred_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn on_deferred_entity_hover( + &mut self, scene: &crate::scene::Scene, handle: Option, point: DVec3, + ) -> Vec { + if !self.needs_entity_pick() { + return Vec::new(); + } + let key = (scene.geometry_epoch, handle, point.to_array().map(f64::to_bits), self.ctrl); + if self.hover_key == Some(key) { + return self.hover_cache.clone(); + } + self.hover_key = Some(key); + self.hover_cache.clear(); + let Ok(target) = crate::scene::model::presspull_model::resolve_target( + scene, handle, point, self.working_plane, self.ctrl, + ) else { return Vec::new(); }; + let geometry = match target.kind { + PresspullTargetKind::Profile { entity, .. } => + crate::scene::model::presspull_model::profile_geometry(&entity) + .map(|(plane, loops, _)| (plane, loops)), + PresspullTargetKind::Face { body, face, .. } => + cadkernel::brep::planar_face_profile(&body, face) + .map(|profile| (profile.plane, profile.loops)), + }; + if let Some((plane, loops)) = geometry { + self.hover_cache = loops.into_iter().flatten().filter_map(|curve| { + let points = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .into_iter().map(|point| plane.point_at(point)).collect::>(); + (points.len() >= 2).then(|| { + let mut wire = WireModel::solid_f64( + "PRESSPULL-BOUNDARY-HOVER".to_owned(), points, [1.0, 0.65, 0.1, 1.0], false, + ); + wire.line_weight_px = 2.0; + wire + }) + }).collect(); + } + self.hover_cache.clone() + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { - if handle.is_null() { + if !self.needs_entity_pick() { return CmdResult::NeedPoint; } - self.picked = Some((handle, point)); - CmdResult::NeedPoint + self.pick(Some(handle), point) } fn on_point(&mut self, point: DVec3) -> CmdResult { - let Some((handle, pick)) = self.picked else { - return CmdResult::NeedPoint; - }; - let distance = self - .direction - .map(|direction| (point - pick).dot(direction)) - .unwrap_or_else(|| point.distance(pick)); - if !distance.is_finite() || distance.abs() <= 1e-6 { - return CmdResult::NeedPoint; + if self.needs_entity_pick() { + return self.pick(None, point); } - CmdResult::PresspullEntity { - handle, - pick, - distance, - drag: Some(point), - color: self.color, + self.height(point).map_or(CmdResult::NeedPoint, |height| self.finish(height)) + } + + fn on_presspull_target(&mut self, mut target: PresspullTarget) { + let Some(direction) = target.direction.try_normalize() else { + return; + }; + if !target.anchor.is_finite() || self.has_target(&target) { + return; + } + target.direction = direction; + self.targets.push(target); + if self.step != PresspullStep::Multiple { + self.step = PresspullStep::Height; + } + self.invalidate_preview(); + } + + fn on_presspull_applied(&mut self, success: bool) { + if success { + self.targets.clear(); + self.step = PresspullStep::Pick; + self.invalidate_preview(); + } else if !self.targets.is_empty() { + self.step = PresspullStep::Height; } } fn wants_text_input(&self) -> bool { - self.picked.is_some() + true + } + + fn point_step_accepts_keywords(&self) -> bool { + self.needs_entity_pick() } fn on_text_input(&mut self, text: &str) -> Option { - let (handle, pick) = self.picked?; - crate::entities::common::parse_typed_length(text) - .filter(|distance| distance.abs() > 1e-6) - .map(|distance| CmdResult::PresspullEntity { - handle, - pick, - distance, - drag: None, - color: self.color, - }) + let text = text.trim(); + if text.is_empty() { + return Some(self.on_enter()); + } + match text.trim_start_matches('_').to_ascii_uppercase().as_str() { + "M" | "MULTIPLE" if !self.targets.is_empty() => { + self.step = PresspullStep::Multiple; + return Some(CmdResult::NeedPoint); + } + "U" | "UNDO" => return Some(self.undo_selection()), + _ => {} + } + // The shared picker resolves coordinates and hexadecimal handles. + // Do not consume either as a distance while selecting targets. + if self.needs_entity_pick() || text.starts_with("0x") || text.starts_with("0X") { + return None; + } + crate::entities::common::parse_typed_length(text).map(|height| self.finish(height)) } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + if self.step == PresspullStep::Multiple && !self.targets.is_empty() { + self.step = PresspullStep::Height; + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_undo_step(&mut self) -> Option { + (!self.targets.is_empty()).then(|| self.undo_selection()) } fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { - Some((self.picked?.1, self.direction?)) + if self.needs_entity_pick() { + return None; + } + let target = self.targets.last()?; + Some((target.anchor, target.direction)) + } + + fn resolved_anchor(&self) -> Option { + self.targets.last().map(|target| target.anchor) } fn dyn_spec(&self) -> Option { - let (_, anchor) = self.picked?; + let (anchor, _) = self.cursor_axis()?; Some(crate::command::DynSpec { anchor: crate::command::DynAnchor::Point(anchor), fields: vec![crate::command::DynFieldSpec::new( @@ -629,8 +855,36 @@ impl CadCommand for PresspullCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - let (_, anchor) = self.picked?; - Some((cursor - anchor).dot(self.direction?)) + if self.needs_entity_pick() { + return None; + } + self.height(cursor) + } + + fn dyn_commit_as_text(&self) -> bool { + !self.needs_entity_pick() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if self.needs_entity_pick() { + return Vec::new(); + } + let Some(height) = self.height(cursor).filter(|height| height.abs() > 1e-6) else { + return Vec::new(); + }; + let key = (self.target_generation, height.to_bits()); + if self.preview_key != Some(key) { + self.preview_cache = self.targets.iter().flat_map(|target| { + crate::scene::model::presspull_model::preview_wires( + target, + height, + self.color, + self.isolines, + ) + }).collect(); + self.preview_key = Some(key); + } + self.preview_cache.clone() } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bbb5d3226..dc16aac6b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2861,7 +2861,24 @@ impl Scene { solid: cadkernel::brep::Body, display: (MeshLodSet, Vec, [f64; 3]), ) -> bool { - let (set, wires, center) = display; + let (mut set, wires, center) = display; + // New command results can be prepared before their handle exists. + // Apply the committed entity's actual style without retessellating. + if let Some(entity) = self.document.get_entity(handle) { + let color = self.render_style(entity).0; + for mesh in &mut set.lods { + mesh.name = handle.value().to_string(); + mesh.color = color; + } + crate::scene::model::material_model::resolve_material_with_base( + &self.document, entity, color, None, self.material_base_dir.as_deref(), + ).apply_to_with_face_overrides( + &mut set, &self.document, self.material_base_dir.as_deref(), + ); + crate::scene::model::visual_style_model::apply_mesh_visual_style( + &mut set, &self.document, entity, + ); + } if let Some(entity) = self.document.get_entity_mut(handle) { let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); match entity { diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index a4666abb8..e55a367ba 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -11,6 +11,7 @@ pub mod mesh_model; pub mod solid_model; pub mod solid_history; pub mod sweep_model; +pub mod presspull_model; pub mod loft_command_model; mod sweep_command_model; pub mod visual_style_model; diff --git a/src/scene/model/presspull_model.rs b/src/scene/model/presspull_model.rs new file mode 100644 index 000000000..387a1ca43 --- /dev/null +++ b/src/scene/model/presspull_model.rs @@ -0,0 +1,376 @@ +//! PRESSPULL selection and curve-only previews. Geometry operations stay in the kernel. + +use acadrust::entities::{AcisData, EmbeddedEntity, Region, Wire}; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; +use cadkernel::brep::{self, Body, FaceKey}; +use cadkernel::geom2d::{contains, ring_nesting_depths, signed_area, Curve, Line, Tolerance}; +use cadkernel::space::Plane; +use glam::DVec3; +use rustc_hash::FxHashMap; + +use crate::command::WorkingPlane; +use crate::scene::{boundary_faces, exact_hatch_paths, ring_source_handles, BoundarySource, Scene}; +use super::{sweep_model, wire_model::WireModel}; + +const BOUNDARY_TOLERANCE: f64 = 1e-6; + +#[derive(Clone)] +pub enum PresspullTargetKind { + Profile { entity: EntityType, source: Option, owner: Option }, + Face { handle: Handle, face: FaceKey, body: Body, offset: bool }, +} + +#[derive(Clone)] +pub struct PresspullTarget { + pub kind: PresspullTargetKind, + pub anchor: DVec3, + pub direction: DVec3, +} + +/// Extract exact embedded REGION loops before falling back to polygonal entities. +/// In particular, preview and final extrusion must not approximate REGION arcs +/// from their display wires or discard holes. +pub fn profile_geometry(entity: &EntityType) -> Option<(Plane, Vec>, bool)> { + if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { + return None; + } + let embedded = match entity { + EntityType::Region(region) => Some(( + EmbeddedEntity::Region(region.clone()), glam::DMat4::IDENTITY.to_cols_array(), + )), + _ => sweep_model::embedded_revolve_profile(entity), + }; + if let Some((entity, transform)) = embedded { + if let Ok(geometry) = cadkernel::acis::sweep_profile_geometry(&entity, transform) { + return Some(geometry); + } + // Exact modeler data is authoritative, not a hint to replace by chords. + if matches!(entity, EmbeddedEntity::Region(ref region) if region.acis_data.has_data()) { + return None; + } + } + let (profile, closed) = sweep_model::extrusion_profile_of(entity)?; + Some((profile.plane, vec![profile.pieces], closed)) +} + +/// Builds the final solid or surface without discarding exact REGION loops. +pub fn extrusion_body(entity: &EntityType, direction: [f64; 3]) -> Option { + let (plane, loops, closed) = profile_geometry(entity)?; + if closed { + brep::extrude_region(plane, &loops, direction) + } else if loops.len() == 1 { + brep::extrude_surface(plane, &loops[0], direction) + } else { + None + } +} + +fn eligible(scene: &Scene, entity: &EntityType) -> bool { + let common = entity.common(); + !common.invisible + && !scene.entity_temporarily_hidden(common.handle) + && !scene.layer_hidden(&common.layer) + && !scene.interaction_layer_frozen(&common.layer) + && !scene.is_layer_locked(common.handle) + && scene.belongs_to_visible_block( + common.handle, common.owner_handle, scene.interaction_block_handle(), + ) + && !crate::scene::annotative::annotative_offscale_for( + &scene.document, common, scene.displayed_annotation_scale_handle(), + scene.annotation_all_visible(), + ) +} + +fn working_plane(plane: Plane) -> WorkingPlane { + WorkingPlane::new( + DVec3::from_array(plane.origin), DVec3::from_array(plane.x_axis), + DVec3::from_array(plane.y_axis), + ) +} + +fn curves_on_plane(source: Plane, curves: &[Curve], plane: WorkingPlane) -> Vec { + let origin = plane.to_local(DVec3::from_array(source.origin)); + let x_axis = plane.vector_to_local(DVec3::from_array(source.x_axis)); + let y_axis = plane.vector_to_local(DVec3::from_array(source.y_axis)); + let transform = cadkernel::geom2d::Transform { + origin: [origin.x, origin.y].into(), + x_axis: [x_axis.x, x_axis.y].into(), + y_axis: [y_axis.x, y_axis.y].into(), + }; + curves.iter().filter_map(|curve| { + // A line belongs to more than one plane. Its arbitrary source axes + // must not disqualify an edge which lies in the requested work plane. + if let Curve::Line(line) = curve { + let start = plane.to_local(DVec3::from_array(source.point_at(line.start))); + let end = plane.to_local(DVec3::from_array(source.point_at(line.end))); + return (start.z.abs() <= BOUNDARY_TOLERANCE && end.z.abs() <= BOUNDARY_TOLERANCE) + .then_some(Curve::Line(Line { start: [start.x, start.y], end: [end.x, end.y] })); + } + if origin.z.abs() > BOUNDARY_TOLERANCE || x_axis.z.abs() > 1e-9 || y_axis.z.abs() > 1e-9 { + return None; + } + curve.transformed(&transform) + }).collect() +} + +fn boundary_source(curves: Vec) -> Option { + let curves = curves.into_iter().filter(|curve| { + let length = curve.length(); + length.is_finite() && length > BOUNDARY_TOLERANCE + }).collect::>(); + let segments = curves.iter().flat_map(|curve| { + curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2).filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + (start.iter().chain(&end).all(|value| value.is_finite()) && start != end) + .then_some(Line { start, end }) + }).collect::>() + }).collect::>(); + (!segments.is_empty()).then_some(BoundarySource { segments, curves }) +} + +fn boundary_sources(scene: &Scene, plane: WorkingPlane) -> FxHashMap { + // Read native curve geometry, not shaded meshes, face isolines, temporary + // construction-history overlays or camera-clipped display segments. + scene.document.entities().filter(|entity| eligible(scene, entity)).filter_map(|entity| { + let (source, loops, _) = profile_geometry(entity)?; + let curves = loops.iter().flat_map(|ring| curves_on_plane(source, ring, plane)).collect(); + boundary_source(curves).map(|source| (entity.common().handle, source)) + }).collect() +} + +fn ring_curves(ring: &[[f64; 2]]) -> Vec { + ring.iter().copied().zip(ring.iter().copied().cycle().skip(1)) + .take(ring.len()).map(|(start, end)| Curve::Line(Line { start, end })).collect() +} + +fn selected_rings(sources: &FxHashMap, point: [f64; 2]) -> Option>> { + let rings = boundary_faces(sources, BOUNDARY_TOLERANCE); + let tolerance = Tolerance::new(BOUNDARY_TOLERANCE); + let outer = rings.iter().enumerate() + .filter(|(_, ring)| contains(&ring_curves(ring), point, tolerance)) + .min_by(|(_, left), (_, right)| signed_area(left).abs().total_cmp(&signed_area(right).abs()))? + .0; + let depths = ring_nesting_depths(&rings); + let boundary = ring_curves(&rings[outer]); + let mut result = vec![rings[outer].clone()]; + for (index, ring) in rings.iter().enumerate() { + if index != outer && depths.get(index) == depths.get(outer).map(|depth| depth + 1).as_ref() + && ring.first().is_some_and(|point| contains(&boundary, *point, tolerance)) + { + result.push(ring.clone()); + } + } + Some(result) +} + +fn boundary_region( + sources: &FxHashMap, rings: &[Vec<[f64; 2]>], plane: WorkingPlane, +) -> Option { + let exterior = (0..rings.len()).map(|index| index == 0).collect::>(); + let paths = exact_hatch_paths(rings, &exterior, sources, BOUNDARY_TOLERANCE); + if paths.len() != rings.len() { + return None; + } + let loops = paths.iter().map(|path| { + path.edges.iter().map(crate::entities::hatch::edge_curve).collect::>>() + }).collect::>>()?; + let kernel_plane = Plane::from_axes(plane.origin.to_array(), plane.x.to_array(), plane.y.to_array()); + let sheet = brep::planar_region(kernel_plane, &loops)?; + let document = crate::scene::convert::acis_export::solid_to_sat(&sheet)?; + let mut region = Region::new(); + region.acis_data = AcisData::from_sat(&document.to_sat_string()); + region.point_of_reference = Vector3::new(plane.origin.x, plane.origin.y, plane.origin.z); + region.wires = loops.iter().map(|ring| { + let mut points = Vec::new(); + for curve in ring { + let skip = usize::from(!points.is_empty()); + points.extend(curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .into_iter().skip(skip).map(|point| { + let world = kernel_plane.point_at(point); + Vector3::new(world[0], world[1], world[2]) + })); + } + Wire::from_points(points) + }).collect(); + Some(EntityType::Region(region)) +} + +fn face_at_point(scene: &Scene, point: DVec3, preferred: Option) -> Option<(Handle, FaceKey, &Body)> { + let mut handles = scene.solid_models.keys().copied().collect::>(); + handles.sort_by_key(|handle| handle.value()); + for handle in handles { + if preferred.is_some_and(|preferred| preferred != handle) { + continue; + } + let Some(entity) = scene.document.get_entity(handle) else { continue; }; + if !matches!(entity, EntityType::Solid3D(_)) || !eligible(scene, entity) { + continue; + } + let body = &scene.solid_models[&handle]; + if let Some(face) = brep::planar_face_at_point(body, point.to_array(), BOUNDARY_TOLERANCE) { + return Some((handle, face, body)); + } + } + None +} + +fn profile_owner(scene: &Scene, plane: WorkingPlane, anchor: DVec3) -> Option<(Handle, DVec3)> { + let mut handles = scene.solid_models.keys().copied().collect::>(); + handles.sort_by_key(|handle| handle.value()); + for handle in handles { + let Some(entity) = scene.document.get_entity(handle) else { continue; }; + if !matches!(entity, EntityType::Solid3D(_)) || !eligible(scene, entity) { + continue; + } + let body = &scene.solid_models[&handle]; + for face in body.face_keys() { + let Some(profile) = brep::planar_face_profile(body, face) else { continue; }; + let outward = DVec3::from_array(profile.outward); + if outward.dot(plane.z).abs() < 1.0 - 1e-9 + || profile.plane.distance_to(anchor.to_array())?.abs() > BOUNDARY_TOLERANCE + { + continue; + } + let local = profile.plane.project(anchor.to_array())?; + let boundary = profile.loops.into_iter().flatten().collect::>(); + if contains(&boundary, local, Tolerance::new(BOUNDARY_TOLERANCE)) { + return Some((handle, outward)); + } + } + } + None +} + +/// A supplied curve handle means explicit edge/preselection. An absent handle +/// means a bounded-area pick; closed profiles therefore retain their distinct +/// edge and interior behaviors. A solid handle prefers its exact trimmed face. +pub fn resolve_target( + scene: &Scene, handle: Option, point: DVec3, plane: WorkingPlane, offset: bool, +) -> Result { + if !point.is_finite() { + return Err("PRESSPULL: the selected point is not finite.".to_owned()); + } + if let Some(handle) = handle { + let entity = scene.document.get_entity(handle) + .ok_or("PRESSPULL: the selected object no longer exists.")?; + if !eligible(scene, entity) { + return Err("PRESSPULL: select a visible object on an unlocked layer.".to_owned()); + } + if let Some((source_plane, _, closed)) = profile_geometry(entity) { + let profile_plane = working_plane(source_plane); + let local = profile_plane.to_local(point); + let anchor = profile_plane.to_world(DVec3::new(local.x, local.y, 0.0)); + let attached = closed.then(|| profile_owner(scene, profile_plane, anchor)).flatten(); + let owner = attached.map(|(owner, _)| owner); + let direction = attached.map(|(_, outward)| outward).unwrap_or(profile_plane.z); + return Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity: entity.clone(), source: Some(handle), owner }, + anchor, direction, + }); + } + if !matches!(entity, EntityType::Solid3D(_)) { + return Err("PRESSPULL: select a planar curve, bounded area or planar solid face.".to_owned()); + } + } + + if let Some((owner, face, body)) = face_at_point(scene, point, handle) { + let profile = brep::planar_face_profile(body, face) + .ok_or("PRESSPULL: the selected solid face has no valid planar boundary.")?; + let face_plane = working_plane(profile.plane); + let local = face_plane.to_local(point); + let anchor = face_plane.to_world(DVec3::new(local.x, local.y, 0.0)); + let direction = DVec3::from_array(profile.outward); + if !offset { + let mut sources = boundary_sources(scene, face_plane); + if let Some(source) = boundary_source(profile.loops.into_iter().flatten().collect()) { + sources.insert(owner, source); + } + if let Some(rings) = selected_rings(&sources, [local.x, local.y]) { + let bounded_by_drawing = rings.iter().any(|ring| { + ring_source_handles(ring, &sources).into_iter().any(|source| source != owner) + }); + if bounded_by_drawing { + let entity = boundary_region(&sources, &rings, face_plane) + .ok_or("PRESSPULL: the selected boundary could not be reconstructed exactly.")?; + return Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity, source: None, owner: Some(owner) }, + anchor, direction, + }); + } + } + } + return Ok(PresspullTarget { + kind: PresspullTargetKind::Face { handle: owner, face, body: body.clone(), offset }, + anchor, direction, + }); + } + if handle.is_some() { + return Err("PRESSPULL: select inside a trimmed planar face, outside its holes.".to_owned()); + } + let local = plane.to_local(point); + if local.z.abs() > BOUNDARY_TOLERANCE { + return Err("PRESSPULL: the bounded area is not on the current working plane.".to_owned()); + } + let sources = boundary_sources(scene, plane); + let rings = selected_rings(&sources, [local.x, local.y]) + .ok_or("PRESSPULL: no closed boundary contains the selected point.")?; + let entity = boundary_region(&sources, &rings, plane) + .ok_or("PRESSPULL: the selected boundary could not be reconstructed exactly.")?; + Ok(PresspullTarget { + kind: PresspullTargetKind::Profile { entity, source: None, owner: None }, + anchor: point, direction: plane.z, + }) +} + +/// Curves only: mouse movement never triangulates a preview body. +pub fn preview_wires(target: &PresspullTarget, distance: f64, color: [f32; 4], isolines: usize) -> Vec { + if !distance.is_finite() || distance.abs() <= 1e-9 { + return Vec::new(); + } + if let PresspullTargetKind::Face { body, face, offset: true, .. } = &target.kind { + // Offset changes the intersections with neighbouring surfaces, so a + // constant-section prism would misrepresent sloped walls and caps. + // The kernel's analytic offset performs no Boolean or triangulation; + // the command memoizes this candidate by selected targets and distance. + // Only return an overlay: failure never hides or edits the resident body. + return brep::presspull_face(body, *face, distance, brep::PresspullMode::Offset) + .map(|candidate| preview_body_wires(&candidate, color, isolines)) + .unwrap_or_default(); + } + let geometry = match &target.kind { + PresspullTargetKind::Profile { entity, .. } => profile_geometry(entity) + .map(|(plane, loops, _)| (plane, loops, (target.direction * distance).to_array())), + PresspullTargetKind::Face { body, face, .. } => { + // The resident source stays visible. Show only the moving face's + // exact extrusion cage; its Boolean merge happens only on commit, + // never inside pointer movement. Unrelated faces and lumps need + // no rebuilding for a normal face-extrusion preview. + brep::planar_face_profile(body, *face).map(|profile| (profile.plane, profile.loops, + (DVec3::from_array(profile.outward) * distance).to_array())) + } + }; + let Some((plane, loops, direction)) = geometry else { return Vec::new(); }; + let mut wires = Vec::new(); + // Each loop is a lateral sheet. Calling extrude_region here would perform + // Boolean differences for holes even though this overlay only needs wires. + for ring in loops { + let ring = brep::extrusion_profile_pieces(&ring); + let Some(body) = brep::extrude_surface(plane, &ring, direction) else { continue; }; + wires.extend(preview_body_wires(&body, color, isolines)); + } + wires +} + +fn preview_body_wires(body: &Body, color: [f32; 4], isolines: usize) -> Vec { + let wireframe = brep::mesh::tessellate_wireframe(body, + brep::mesh::TessellationTolerance::new(cadkernel::tessellation::DEFAULT_ANGLE, 1e-9) + .with_isolines(isolines)); + wireframe.edges.into_iter().map(|edge| edge.positions) + .chain(wireframe.isolines.into_iter().map(|line| line.positions)) + .filter(|points| points.len() >= 2) + .map(|points| WireModel::solid_f64("PRESSPULL-PREVIEW".to_owned(), points, color, false)) + .collect() +} diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 412ca4e8a..37293ac70 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -67,6 +67,9 @@ pub const PROP_TWIST_ALONG_PATH: &str = "solid_history_twist_along_path"; pub const PROP_SCALE_ALONG_PATH: &str = "solid_history_scale_along_path"; pub const PROP_SWEEP_LENGTH: &str = "solid_history_sweep_length"; pub const PROP_EXTRUSION_HEIGHT: &str = "solid_history_extrusion_height"; +pub const PROP_EXTRUSION_DIRECTION_X: &str = "solid_history_extrusion_direction_x"; +pub const PROP_EXTRUSION_DIRECTION_Y: &str = "solid_history_extrusion_direction_y"; +pub const PROP_EXTRUSION_DIRECTION_Z: &str = "solid_history_extrusion_direction_z"; pub const PROP_TAPER_ANGLE: &str = "solid_history_taper_angle"; pub const PROP_REVOLVE_ANGLE: &str = "solid_history_revolve_angle"; pub const PROP_AXIS_POSITION_X: &str = "solid_history_axis_position_x"; @@ -357,15 +360,19 @@ fn extrusion_properties( handle: acadrust::Handle, value: &SolidHistorySweep, ) -> Vec { - let position = world_point( - value.base.transform, - [ - value.reference_point.x, - value.reference_point.y, - value.reference_point.z, - ], - ) - .unwrap_or(glam::DVec3::ZERO); + let direction = matrix(value.base.transform).map(|transform| { + transform.transform_vector3(glam::DVec3::new( + value.direction.x, value.direction.y, value.direction.z, + )) + }).filter(|direction| direction.is_finite()); + let 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| 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) = @@ -391,21 +398,6 @@ fn extrusion_properties( field: "solid_history_type", value: PropValue::ReadOnly(t!("Extrusion").into_owned()), }, - crate::entities::common::edit_prop( - t!("Position X").as_ref(), - PROP_POSITION_X, - position.x, - ), - crate::entities::common::edit_prop( - t!("Position Y").as_ref(), - PROP_POSITION_Y, - position.y, - ), - crate::entities::common::edit_prop( - t!("Position Z").as_ref(), - PROP_POSITION_Z, - position.z, - ), Property { label: t!("Height").into_owned(), field: PROP_EXTRUSION_HEIGHT, @@ -416,6 +408,9 @@ fn extrusion_properties( field: PROP_TAPER_ANGLE, value: taper, }, + direction_property(t!("Direction X").as_ref(), PROP_EXTRUSION_DIRECTION_X, 0), + direction_property(t!("Direction Y").as_ref(), PROP_EXTRUSION_DIRECTION_Y, 1), + direction_property(t!("Direction Z").as_ref(), PROP_EXTRUSION_DIRECTION_Z, 2), ], }, PropSection { @@ -426,7 +421,7 @@ fn extrusion_properties( field: PROP_HISTORY, value: PropValue::Choice { selected: if record_history { "Record" } else { "None" }.to_string(), - options: vec!["None".to_string(), "Record".to_string()], + options: vec!["Record".to_string(), "None".to_string()], }, }, Property { @@ -435,7 +430,7 @@ fn extrusion_properties( value: if show_history_editable { PropValue::Choice { selected: if show_history { "Yes" } else { "No" }.to_string(), - options: vec!["No".to_string(), "Yes".to_string()], + options: vec!["Yes".to_string(), "No".to_string()], } } else { PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) @@ -446,6 +441,43 @@ fn extrusion_properties( ] } +/// Native surface construction data mirrors the same local profile, placement, +/// direction and taper used by the extrusion history rebuild. +pub fn extrusion_surface_data( + value: &SolidHistorySweep, +) -> Option { + use acadrust::entities::{SurfaceData, SurfaceSweepOptions}; + + if value.path_entity.is_some() { + return None; + } + let profile = value.sweep_entity.clone()?; + matrix(value.base.transform)?; + matrix(value.sweep_entity_transform)?; + Some(SurfaceData::Extruded { + sweep_entity: Some(profile), + options: SurfaceSweepOptions { + draft_angle: value.draft_angle, + draft_start_distance: value.start_draft_distance, + draft_end_distance: value.end_draft_distance, + twist_angle: value.twist_angle, + scale_factor: value.scale_factor, + align_angle: value.align_angle, + sweep_entity_transform: value.sweep_entity_transform, + path_entity_transform: value.path_entity_transform, + is_solid: false, + sweep_alignment_flags: value.align_option as i16, + align_start: value.has_align_start, + bank: value.bank, + base_point_set: true, + reference_vector: value.reference_point, + ..SurfaceSweepOptions::default() + }, + sweep_vector: value.direction, + sweep_transform: value.base.transform, + }) +} + fn sweep_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -1439,7 +1471,8 @@ 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_LOFT_TYPE | PROP_LOFT_SECTION_COUNT + | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z) || is_primitive_property(field) || is_history_choice(field) } @@ -2977,7 +3010,15 @@ fn apply_extrusion_draft_grip(value: &mut SolidHistorySweep, apply: GripApply) - fn extrusion_draft_rebuilds(value: &SolidHistorySweep, angle: f64) -> bool { let mut candidate = value.clone(); candidate.draft_angle = angle; - cadkernel::acis::rebuild_body(&SolidHistoryOperation::Extrusion(candidate)).is_ok() + let Some(profile) = candidate.sweep_entity.as_ref() else { + return false; + }; + let Ok((_, _, closed)) = cadkernel::acis::sweep_profile_geometry( + profile, candidate.sweep_entity_transform, + ) else { + return false; + }; + cadkernel::acis::rebuild_extrusion_with_mode(&candidate, !closed).is_ok() } fn grip( diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 93a17416b..da76502f0 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -810,14 +810,16 @@ impl Scene { let Some(reference) = reference else { return; }; - let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { - return; - }; - entity.point_of_reference = acadrust::types::Vector3::new( + let point = acadrust::types::Vector3::new( reference.x, reference.y, reference.z, ); + match self.document.get_entity_mut(handle) { + Some(EntityType::Solid3D(entity)) => entity.point_of_reference = point, + Some(EntityType::Surface(entity)) => entity.point_of_reference = point, + _ => {} + } } fn copy_solid_history(&mut self, source: Handle, target: Handle) -> bool { @@ -832,34 +834,74 @@ impl Scene { } pub(crate) fn delete_solid_history(&mut self, handle: Handle) { + if self.is_recording_undo() { + // The codec also clears the entity's history handle. Capture that + // link before deletion so undo reconnects the restored graph. + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } self.record_solid_history_before(handle); self.document.delete_solid_history(handle); } + fn rebuild_history_body( + &self, + handle: Handle, + operation: &acadrust::objects::SolidHistoryOperation, + ) -> Option { + use acadrust::objects::SolidHistoryOperation; + + match (self.document.get_entity(handle)?, operation) { + (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { + cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok() + } + (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) + | (EntityType::Solid3D(_), _) => cadkernel::acis::rebuild_body(operation).ok(), + _ => None, + } + } + + fn history_surface_data( + &self, + handle: Handle, + operation: &acadrust::objects::SolidHistoryOperation, + ) -> Option> { + use acadrust::objects::SolidHistoryOperation; + + match (self.document.get_entity(handle)?, operation) { + (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { + crate::scene::model::solid_history::extrusion_surface_data(value).map(Some) + } + (EntityType::Surface(_), SolidHistoryOperation::Loft(value)) => { + let EntityType::Surface(surface) = + crate::scene::model::loft_command_model::surface_entity(value) + else { return None; }; + Some(Some(surface.surface_data)) + } + (EntityType::Solid3D(_), _) => Some(None), + _ => None, + } + } + pub fn rebuild_solid_history( &mut self, handle: Handle, operation: acadrust::objects::SolidHistoryOperation, ) -> bool { - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; - let loft_surface_data = match (&operation, self.document.get_entity(handle)) { - (acadrust::objects::SolidHistoryOperation::Loft(record), Some(EntityType::Surface(_))) => { - let EntityType::Surface(surface) = crate::scene::model::loft_command_model::surface_entity(record) - else { return false; }; - Some(surface.surface_data) - } - (_, Some(EntityType::Solid3D(_))) => None, - _ => return false, + let Some(surface_data) = self.history_surface_data(handle, &operation) else { + return false; }; let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_)) && !display.0.complete { return false; @@ -876,7 +918,12 @@ impl Scene { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - if let Some(data) = loft_surface_data { entity.surface_data = data; } + if let Some(data) = surface_data { + if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { + entity.kind = acadrust::entities::SurfaceKind::Extruded; + } + entity.surface_data = data; + } } _ => return false, } @@ -889,16 +936,20 @@ impl Scene { let Some(operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; + let Some(surface_data) = self.history_surface_data(handle, &operation) else { + return false; + }; let Some(display) = self.prepare_solid_model_display(handle, &body) else { return false; }; - if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_)) + if matches!(&operation, acadrust::objects::SolidHistoryOperation::Loft(_) + | acadrust::objects::SolidHistoryOperation::Extrusion(_)) && !display.0.complete { return false; @@ -907,10 +958,11 @@ impl Scene { Some(EntityType::Solid3D(entity)) => entity.set_sat_document(&document), Some(EntityType::Surface(entity)) => { entity.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - if let acadrust::objects::SolidHistoryOperation::Loft(record) = &operation { - if let EntityType::Surface(updated) = crate::scene::model::loft_command_model::surface_entity(record) { - entity.surface_data = updated.surface_data; + if let Some(data) = surface_data { + if matches!(&data, acadrust::entities::SurfaceData::Extruded { .. }) { + entity.kind = acadrust::entities::SurfaceKind::Extruded; } + entity.surface_data = data; } } _ => return false, @@ -925,9 +977,12 @@ impl Scene { handle: Handle, operation: acadrust::objects::SolidHistoryOperation, ) -> bool { - let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { + let Some(body) = self.rebuild_history_body(handle, &operation) else { return false; }; + if self.history_surface_data(handle, &operation).is_none() { + return false; + } if self .document .update_solid_history(handle, operation) @@ -935,9 +990,6 @@ impl Scene { { return false; } - if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_) | EntityType::Surface(_))) { - return false; - } self.solid_history_preview_wires.insert( handle, crate::scene::model::solid_model::grip_preview_wires(&body, handle), @@ -960,6 +1012,15 @@ impl Scene { return false; } if let EntityTransform::Translate(delta) = transform { + let surface_data = match (&operation, self.document.get_entity(handle)) { + (acadrust::objects::SolidHistoryOperation::Extrusion(value), Some(EntityType::Surface(_))) => { + let Some(data) = crate::scene::model::solid_history::extrusion_surface_data(value) else { + return false; + }; + Some(data) + } + _ => None, + }; if self .document .update_solid_history(handle, operation) @@ -967,6 +1028,11 @@ impl Scene { { return false; } + if let (Some(data), Some(EntityType::Surface(entity))) = + (surface_data, self.document.get_entity_mut(handle)) + { + entity.surface_data = data; + } self.translate_solid_geometry(handle, delta.to_array()); return true; }