diff --git a/Cargo.lock b/Cargo.lock index 54c4524bf..671ff7c53 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=93f7aa4efa61eeaf3aeefbdbaff6078283820df6#93f7aa4efa61eeaf3aeefbdbaff6078283820df6" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f#5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index b7e775b94..1e2ce7451 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 = "393d6c0857447f9ca678bed97a612707f0a842c6", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "93f7aa4efa61eeaf3aeefbdbaff6078283820df6", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5fc881c0f6fbc7fb96d483cd3a4efe145ba6133f", 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/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 7a73eebd1..1f1207e22 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -715,7 +715,9 @@ common = .quadrant = Quadrant .quality = Quality .revolve-could-not-revolve-profile = REVOLVE: could not revolve profile. + .revolve-created-objects-failed-sources = REVOLVE: __ocs_arg_created__ object(s) created; __ocs_arg_failed__ source(s) could not be revolved. .revolve-entity-not-found = REVOLVE: entity not found. + .revolve-no-selected-object-could-be-revolved = REVOLVE: no selected object could be revolved with the requested options. .revolve-solid-created-0 = REVOLVE: solid created (__ocs_fmt_0__°). .radians = Radians .radius = Radius @@ -2064,7 +2066,14 @@ insert = .revolve-angle-of-revolution-360 = REVOLVE Angle of revolution <360>: .revolve-axis-end-point = REVOLVE Axis end point: .revolve-axis-start-point = REVOLVE Axis start point: + .revolve-creation-mode-solid-surface = REVOLVE Creation mode [Solid/Surface] + .revolve-enter-angle-expression = REVOLVE Enter angle expression: + .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Select line, ray, or construction line for axis: + .revolve-select-objects-or-mode = REVOLVE Select objects to revolve or [Mode] (Enter to finish): .revolve-select-profile = REVOLVE Select profile: + .revolve-specify-angle-or-options = REVOLVE Specify angle of revolution or [Start angle/Reverse/Expression] + .revolve-specify-axis-start-or-options = REVOLVE Specify axis start point or [Object/X/Y/Z/Mode]: + .revolve-specify-start-angle = REVOLVE Specify start angle .sweep-select-path-line-arc-lwpolyline = SWEEP Select path (Line, Arc, LwPolyline): .sweep-select-profile-to-sweep = SWEEP Select profile to sweep: .set-base-point = @@ -2434,6 +2443,7 @@ properties = .alt-suppress-trailing-zeros = Alt suppress trailing zeros .ambient = Ambient .angle-format = Angle format + .angle-of-revolution = Angle of revolution .angle-precision = Angle precision .annotation-scale = Annotation scale .annotative-scale = Annotative scale @@ -2449,6 +2459,12 @@ properties = .at-least = At least .attachment-type = Attachment type .attachment = Attachment + .axis-direction-x = Axis direction X + .axis-direction-y = Axis direction Y + .axis-direction-z = Axis direction Z + .axis-position-x = Axis position X + .axis-position-y = Axis position Y + .axis-position-z = Axis position Z .arrowhead-size = Arrowhead Size .attenuation = Attenuation .attribute-definition = Attribute Definition @@ -2879,6 +2895,7 @@ properties = .show = Show .show-clipping = Show Clipping .show-cropping = Show Cropping + .show-history = Show History .show-intensity = Show Intensity .show-clipped = Show clipped .show-image = Show image @@ -2886,6 +2903,8 @@ properties = .singleline = SingleLine .softness = Softness .solid = Solid + .solid-history = Solid History + .solid-type = Solid type .solid3d = Solid3D .source-files = Source Files .source-block = Source block diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 7a757079b..8ba49644c 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -702,7 +702,9 @@ common = .quadrant = Çeyrek Nokta .quality = Kalite .revolve-could-not-revolve-profile = REVOLVE: Profil döndürülemedi. + .revolve-created-objects-failed-sources = REVOLVE: __ocs_arg_created__ nesne oluşturuldu; __ocs_arg_failed__ kaynak döndürülemedi. .revolve-entity-not-found = REVOLVE: Nesne bulunamadı. + .revolve-no-selected-object-could-be-revolved = REVOLVE: Seçilen nesnelerin hiçbiri istenen seçeneklerle döndürülemedi. .revolve-solid-created-0 = REVOLVE: Katı oluşturuldu (__ocs_fmt_0__°). .radians = Radyan .radius = Yarıçap @@ -2030,7 +2032,14 @@ insert = .revolve-angle-of-revolution-360 = REVOLVE Döndürme açısı <360>: .revolve-axis-end-point = REVOLVE Eksen bitiş noktası: .revolve-axis-start-point = REVOLVE Eksen başlangıç noktası: + .revolve-creation-mode-solid-surface = REVOLVE Oluşturma modu [Solid/Surface] + .revolve-enter-angle-expression = REVOLVE Açı ifadesini girin: + .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Eksen için çizgi, ışın veya sonsuz çizgi seçin: + .revolve-select-objects-or-mode = REVOLVE Döndürülecek nesneleri seçin veya [Mode] (bitirmek için Enter): .revolve-select-profile = REVOLVE Profili seçin: + .revolve-specify-angle-or-options = REVOLVE Döndürme açısını belirtin veya [Start angle/Reverse/Expression] + .revolve-specify-axis-start-or-options = REVOLVE Eksen başlangıç noktasını belirtin veya [Object/X/Y/Z/Mode]: + .revolve-specify-start-angle = REVOLVE Başlangıç açısını belirtin .sweep-select-path-line-arc-lwpolyline = SWEEP Yolu seçin (Çizgi, Yay, Hafif Çoklu Çizgi): .sweep-select-profile-to-sweep = SWEEP Süpürülecek profili seçin: .set-base-point = Taban Noktasını Ayarla @@ -2375,6 +2384,7 @@ properties = .alt-suppress-trailing-zeros = Alternatif birimlerde sondaki sıfırları gizle .ambient = Ortam .angle-format = Açı biçimi + .angle-of-revolution = Döndürme açısı .angle-precision = Açı hassasiyeti .annotation-scale = Açıklama ölçeği .annotative-scale = Açıklamalı ölçek @@ -2390,6 +2400,12 @@ properties = .at-least = En az .attachment-type = Bağlantı türü .attachment = Bağlantı + .axis-direction-x = Eksen yönü X + .axis-direction-y = Eksen yönü Y + .axis-direction-z = Eksen yönü Z + .axis-position-x = Eksen konumu X + .axis-position-y = Eksen konumu Y + .axis-position-z = Eksen konumu Z .arrowhead-size = Ok Ucu Boyutu .attenuation = Zayıflama .attribute-definition = Öznitelik Tanımı @@ -2819,6 +2835,7 @@ properties = .show = Göster .show-clipping = Kırpmayı Göster .show-cropping = Kırpmayı Göster + .show-history = Geçmişi Göster .show-intensity = Yoğunluğu Göster .show-clipped = Kırpılanı göster .show-image = Görüntüyü göster @@ -2826,6 +2843,8 @@ properties = .singleline = Tek Satır .softness = Yumuşaklık .solid = Katı + .solid-history = Katı Geçmişi + .solid-type = Katı türü .solid3d = 3B Katı .source-files = Kaynak Dosyalar .source-block = Kaynak blok diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e11d4d0e1..e45e8ca8f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3827,66 +3827,144 @@ impl OpenCADStudio { } // ── REVOLVE ──────────────────────────────────────────────────── - CmdResult::RevolveEntity { - handle, + CmdResult::RevolveEntities { + handles, axis_start, axis_end, - angle_deg, + angle, + start_angle, + mode, color: _, } => { - if self.reject_locked_edit(i, handle) { + if handles.is_empty() { 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::none(); } - use crate::modules::insert::solid3d_cmds::empty_solid3d; + let delete_sources = self.tabs[i].scene.document.header.delete_objects; + use crate::command::ExtrudeMode; + use crate::modules::insert::solid3d_cmds::{ + empty_revolved_surface, empty_solid3d, + }; use crate::scene::model::sweep_model; - - let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); - if let Some(entity) = entity_opt { - let from = [ - axis_start.x as f64, - axis_start.y as f64, - axis_start.z as f64, - ]; - let to = [axis_end.x as f64, axis_end.y as f64, axis_end.z as f64]; - let angle = (angle_deg as f64).to_radians(); - let result = sweep_model::revolve_history(&entity, from, to, angle) + let from = axis_start.to_array(); + let to = axis_end.to_array(); + let mut editable_handles = Vec::with_capacity(handles.len()); + let mut failed = 0usize; + for handle in handles { + if self.reject_locked_edit(i, handle) { + failed += 1; + } else { + editable_handles.push(handle); + } + } + let pending = if editable_handles.is_empty() { + None + } else { + self.begin_undo(i, "REVOLVE", editable_handles.len(), true) + }; + let mut created_handles = Vec::new(); + let mut consumed = Vec::new(); + for handle in &editable_handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() + else { + failed += 1; + continue; + }; + let Some((_, closed)) = sweep_model::extrusion_profile_of(&entity) else { + failed += 1; + continue; + }; + let creates_surface = mode == ExtrudeMode::Surface || !closed; + let created = if creates_surface { + let Some(body) = + sweep_model::revolved_surface(&entity, from, to, angle, start_angle) + else { + failed += 1; + continue; + }; + self.add_surface_model( + empty_revolved_surface( + &entity, + axis_start, + axis_end, + angle, + start_angle, + ), + body, + ) + } else { + let result = sweep_model::revolve_history( + &entity, + from, + to, + angle, + start_angle, + ) .and_then(|history| { cadkernel::acis::rebuild_body(&history) .ok() .map(|solid| (solid, history)) }) .or_else(|| { - sweep_model::revolved(&entity, from, to, angle).map(|solid| { - let history = - crate::scene::model::solid_history::brep_op(&solid); - (solid, history) - }) + sweep_model::revolved(&entity, from, to, angle, start_angle).map( + |solid| { + let history = + crate::scene::model::solid_history::brep_op(&solid); + (solid, history) + }, + ) }); - if let Some((solid, history)) = result { - let pending = self.begin_undo(i, "REVOLVE", 1, true); - let created = self.add_solid_model(empty_solid3d(), solid, history); - if !created.is_null() { - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref(), - ); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - } + let Some((solid, history)) = result else { + failed += 1; + continue; + }; + self.add_solid_model(empty_solid3d(), solid, history) + }; + if created.is_null() { + failed += 1; } else { - self.command_line - .push_error(crate::t!("REVOLVE: could not revolve profile.").as_ref()); + created_handles.push(created); + if delete_sources { + consumed.push(*handle); + } } + } + if delete_sources && !created_handles.is_empty() { + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); + } + if !created_handles.is_empty() { + self.tabs[i].scene.deselect_all(); + for handle in &created_handles { + self.tabs[i].scene.select_entity(*handle, true); + } + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!( + "REVOLVE: created %{created} object(s); %{failed} source(s) could not be revolved.", + created = created_handles.len(), + failed = failed + ) + .as_ref(), + ); } else { - self.command_line - .push_error(crate::t!("REVOLVE: entity not found.").as_ref()); + self.command_line.push_error( + crate::t!("REVOLVE: no selected object could be revolved with the requested options.") + .as_ref(), + ); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); + self.refresh_properties(); } // ── SWEEP ───────────────────────────────────────────────────── CmdResult::SweepEntity { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ed44e08af..a27dfade3 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -794,8 +794,18 @@ impl OpenCADStudio { // ── REVOLVE ──────────────────────────────────────────────────── "REVOLVE" => { use crate::modules::insert::solid3d_cmds::RevolveCommand; + 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 cmd = RevolveCommand::new(color); + let isolines = self.tabs[i].scene.document.header.isolines.max(0) as usize; + let mut cmd = RevolveCommand::new(color, isolines); + if !selected.is_empty() { + cmd.set_preselection( + selected + .iter() + .map(|(handle, entity)| (*handle, (*entity).clone())) + .collect(), + ); + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 43f174279..e353de6c8 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -36,7 +36,13 @@ impl super::OpenCADStudio { return Handle::NULL; }; self.tabs[i].scene.create_solid_history(handle, history); - self.tabs[i].scene.register_solid_model(handle, solid); + if !self.tabs[i].scene.register_solid_model(handle, solid) { + // A command result is not successful until it has renderable + // geometry. Roll the new entity back so DELOBJ cannot consume a + // visible source in exchange for an invisible result. + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } handle } @@ -63,7 +69,10 @@ impl super::OpenCADStudio { let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; - self.tabs[i].scene.register_solid_model(handle, surface); + if !self.tabs[i].scene.register_solid_model(handle, surface) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } handle } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1e232cf7e..feaf6b016 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -660,7 +660,16 @@ impl OpenCADStudio { // the sign of the cursor's side relative to the reference, so the // committed direction matches the drag (the box shows magnitude // only). Commands receive an already-signed string. - let text = self.dyn_sign_angle_text(i, text); + let auto_sign = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.dyn_auto_sign_angle()) + .unwrap_or(true); + let text = if auto_sign { + self.dyn_sign_angle_text(i, text) + } else { + text + }; let result = self.tabs[i] .active_cmd .as_mut() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e62e16961..ea9c5c5de 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -462,13 +462,19 @@ impl OpenCADStudio { // clearing Z afterwards shifts the picked point on screen in an // oblique view. Plain paper space still uses the camera target plane. let plane = if self.tabs[i].editing_model_space() { - Some(match self.tabs[i].active_ucs.as_ref() { - Some(ucs) => ( - ucs_z_axis(ucs), - glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z), - ), - None => (glam::DVec3::Z, glam::DVec3::ZERO), - }) + self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.cursor_plane()) + .or_else(|| { + Some(match self.tabs[i].active_ucs.as_ref() { + Some(ucs) => ( + ucs_z_axis(ucs), + glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z), + ), + None => (glam::DVec3::Z, glam::DVec3::ZERO), + }) + }) } else { None }; @@ -1908,12 +1914,17 @@ impl OpenCADStudio { && !command.needs_tangent_pick() && !command.is_selection_gathering() }); + let uses_command_cursor_plane = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.cursor_plane()) + .is_some(); let axis_lock = if let Some(base) = self.last_point { self.active_axis_lock( i, cursor_world, base, - wants_point && !is_window_corner, + wants_point && !is_window_corner && !uses_command_cursor_plane, ) } else { self.axis_lock_dir = None; @@ -2040,7 +2051,7 @@ impl OpenCADStudio { let osnap_locked = self.tabs[i] .snap_result .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); - if !osnap_locked && !is_window_corner { + if !osnap_locked && !is_window_corner && !uses_command_cursor_plane { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { @@ -2070,7 +2081,10 @@ impl OpenCADStudio { }; // Clamp to world XY only when no UCS is active; with a // UCS the point already lies on the UCS XY plane. - if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() { + if self.tabs[i].active_cmd.is_some() + && self.tabs[i].active_ucs.is_none() + && !uses_command_cursor_plane + { pt.z = 0.0; } pt @@ -3367,7 +3381,14 @@ impl OpenCADStudio { let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is // already constrained to that plane by the ray–plane intersection. - if self.tabs[i].active_ucs.is_none() { + let uses_command_cursor_plane = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.cursor_plane()) + .is_some(); + let clamp_world_xy = + self.tabs[i].active_ucs.is_none() && !uses_command_cursor_plane; + if clamp_world_xy { pt.z = 0.0; } let axis_lock = if let Some(base) = self.last_point { @@ -3375,7 +3396,10 @@ impl OpenCADStudio { i, raw, base, - !needs_entity_click && !needs_tan && !is_window_corner, + !needs_entity_click + && !needs_tan + && !is_window_corner + && !uses_command_cursor_plane, ) } else { self.axis_lock_dir = None; @@ -3383,7 +3407,7 @@ impl OpenCADStudio { }; if let (Some(dir), Some(base)) = (axis_lock, self.last_point) { pt = axis_lock_apply(pt, base, dir); - if self.tabs[i].active_ucs.is_none() { + if clamp_world_xy { pt.z = 0.0; } } @@ -3403,11 +3427,12 @@ impl OpenCADStudio { }; if let Some(h) = otrack { pt = h.aligned; - if self.tabs[i].active_ucs.is_none() { + if clamp_world_xy { pt.z = 0.0; } } else if axis_lock.is_none() && !is_window_corner + && !uses_command_cursor_plane && !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) { // Object snap wins over ortho/polar — a snapped point diff --git a/src/command.rs b/src/command.rs index 4aec1e3e3..cc25ff607 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1443,12 +1443,14 @@ pub enum CmdResult { drag: Option, color: [f32; 4], }, - /// Revolve the profile entity `handle` around the given axis by `angle_deg`. - RevolveEntity { - handle: Handle, + /// Revolve the profile entities around the given axis. + RevolveEntities { + handles: Vec, axis_start: glam::DVec3, axis_end: glam::DVec3, - angle_deg: f32, + angle: f64, + start_angle: f64, + mode: ExtrudeMode, color: [f32; 4], }, /// Sweep the profile entity `profile_handle` along `path_handle`. @@ -1776,6 +1778,12 @@ pub trait CadCommand: Send { None } + /// Override the model-space plane onto which viewport cursor rays are + /// projected for this command step. + fn cursor_plane(&self) -> Option<(DVec3, DVec3)> { + None + } + /// Mid-command Ctrl+Z: a multi-point drawing command can take the undo /// itself (PLINE pops its last vertex) instead of the document undo /// swallowing the whole in-progress object. `None` (default) lets the @@ -2161,6 +2169,12 @@ pub trait CadCommand: Send { false } + /// Whether a bare dynamic angle inherits its sign from the world-XY + /// cursor side. Commands with their own 3D axis semantics opt out. + fn dyn_auto_sign_angle(&self) -> bool { + true + } + /// Explicit per-step dynamic-input description. `Some(spec)` takes full /// control of the boxes, guide geometry and anchor for this step; `None` /// (the default) falls back to the legacy `dyn_field()` behaviour so diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 510dd9adf..0356c9e97 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -283,6 +283,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Angle = 0.0000° (degenerate rays)" => Some(("inquiry", "angle-0-0000-degenerate-rays")), "Angle format" => Some(("properties", "angle-format")), "Angle precision" => Some(("properties", "angle-precision")), + "Angle of revolution" => Some(("properties", "angle-of-revolution")), "Angles" => Some(("model", "angles")), "Angular" => Some(("annotate", "angular")), "Angular decimals (DIMADEC)" => Some(("common", "angular-decimals-dimadec")), @@ -357,6 +358,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Axis Point" => Some(("model", "axis-point")), "Axis Vector" => Some(("model", "axis-vector")), "Axis, End" => Some(("draw", "axis-end")), + "Axis direction X" => Some(("properties", "axis-direction-x")), + "Axis direction Y" => Some(("properties", "axis-direction-y")), + "Axis direction Z" => Some(("properties", "axis-direction-z")), + "Axis position X" => Some(("properties", "axis-position-x")), + "Axis position Y" => Some(("properties", "axis-position-y")), + "Axis position Z" => Some(("properties", "axis-position-z")), "BACKGROUND colour [Default/Black/DarkGray/Gray/LightGray/White] or R G B (0–255):" => Some(("file", "background-colour-default-black-darkgray-gray")), "BASE Specify base point:" => Some(("insert", "base-specify-base-point")), "BEDIT Select block reference to edit:" => Some(("modify", "bedit-select-block-reference-to-edit")), @@ -2177,9 +2184,18 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "REVOLVE Angle of revolution <360>:" => Some(("insert", "revolve-angle-of-revolution-360")), "REVOLVE Axis end point:" => Some(("insert", "revolve-axis-end-point")), "REVOLVE Axis start point:" => Some(("insert", "revolve-axis-start-point")), + "REVOLVE Creation mode [Solid/Surface]" => Some(("insert", "revolve-creation-mode-solid-surface")), + "REVOLVE Enter angle expression:" => Some(("insert", "revolve-enter-angle-expression")), + "REVOLVE Select line, ray, or construction line for axis:" => Some(("insert", "revolve-select-line-ray-or-construction-line-for-axis")), + "REVOLVE Select objects to revolve or [Mode] (Enter to finish):" => Some(("insert", "revolve-select-objects-or-mode")), "REVOLVE Select profile:" => Some(("insert", "revolve-select-profile")), + "REVOLVE Specify angle of revolution or [Start angle/Reverse/Expression]" => Some(("insert", "revolve-specify-angle-or-options")), + "REVOLVE Specify axis start point or [Object/X/Y/Z/Mode]:" => Some(("insert", "revolve-specify-axis-start-or-options")), + "REVOLVE Specify start angle" => Some(("insert", "revolve-specify-start-angle")), + "REVOLVE: created %{created} object(s); %{failed} source(s) could not be revolved." => Some(("common", "revolve-created-objects-failed-sources")), "REVOLVE: could not revolve profile." => Some(("common", "revolve-could-not-revolve-profile")), "REVOLVE: entity not found." => Some(("common", "revolve-entity-not-found")), + "REVOLVE: no selected object could be revolved with the requested options." => Some(("common", "revolve-no-selected-object-could-be-revolved")), "REVOLVE: solid created ({:.0}°)." => Some(("common", "revolve-solid-created-0")), "ROTATE Specify first reference point or type reference angle:" => Some(("modify", "rotate-specify-first-reference-point-or-type-r")), "ROTATE Specify new absolute angle [ref=%{a}]:" => Some(("modify", "rotate-specify-new-absolute-angle-ref-a")), @@ -2477,6 +2493,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Show Annotation Objects" => Some(("ui", "show-annotation-objects")), "Show Clipping" => Some(("properties", "show-clipping")), "Show Cropping" => Some(("properties", "show-cropping")), + "Show History" => Some(("properties", "show-history")), "Show Intensity" => Some(("properties", "show-intensity")), "Show Lineweight" => Some(("ui", "show-lineweight")), "Show Lineweight\nLWDISPLAY" => Some(("ui", "show-lineweight-lwdisplay")), @@ -2494,6 +2511,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Snap to\nUnderlays" => Some(("insert", "snap-to-underlays")), "Softness" => Some(("properties", "softness")), "Solid" => Some(("properties", "solid")), + "Solid History" => Some(("properties", "solid-history")), + "Solid type" => Some(("properties", "solid-type")), "Solid created." => Some(("common", "solid-created")), "Solid3D" => Some(("properties", "solid3d")), "Source Files" => Some(("properties", "source-files")), diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index da478712e..21b8c8fdf 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -6,7 +6,7 @@ use acadrust::{entities::Solid3D, EntityType, Handle}; use glam::DVec3; use crate::command::{ - CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, + CadCommand, CmdOption, CmdResult, ExtrudeExtent, ExtrudeMode, SelectionEntity, WorkingPlane, }; use crate::scene::WireModel; use crate::t; @@ -156,22 +156,28 @@ impl ExtrudeCommand { self.taper_angle, ) }; - body.map(|body| preview_body_wires(&body, self.color)) + body.map(|body| preview_body_wires(&body, self.color, 0)) .unwrap_or_default() }) .collect() } } -fn preview_body_wires(body: &cadkernel::brep::Body, color: [f32; 4]) -> Vec { +fn preview_body_wires( + body: &cadkernel::brep::Body, + color: [f32; 4], + isolines: usize, +) -> Vec { let mesh = cadkernel::brep::mesh::tessellate( body, cadkernel::brep::mesh::TessellationTolerance::new( cadkernel::tessellation::DEFAULT_ANGLE, 1e-9, - ), + ) + .with_isolines(isolines), ); - mesh.edges + let mut wires = mesh + .edges .into_iter() .filter(|edge| edge.positions.len() >= 2) .map(|edge| { @@ -182,7 +188,21 @@ fn preview_body_wires(body: &cadkernel::brep::Body, color: [f32; 4]) -> Vec>(); + wires.extend( + mesh.isolines + .into_iter() + .filter(|line| line.positions.len() >= 2) + .map(|line| { + WireModel::solid_f64( + "REVOLVE-PREVIEW-ISOLINE".to_owned(), + line.positions, + color, + false, + ) + }), + ); + wires } impl CadCommand for ExtrudeCommand { @@ -615,30 +635,236 @@ impl CadCommand for PresspullCommand { pub struct RevolveCommand { step: RevolveStep, - target_handle: acadrust::Handle, + handles: Vec, + preview_profiles: Vec<(Handle, EntityType)>, + injected_axis: Option, axis_start: DVec3, axis_end: DVec3, + working_plane: WorkingPlane, + mode: ExtrudeMode, + mode_return: RevolveStep, + angle: f64, + start_angle: f64, + reverse: bool, + isolines: usize, color: [f32; 4], } -#[derive(PartialEq)] +#[derive(Clone, Copy)] +struct RevolveDefaults { + mode: ExtrudeMode, + angle: f64, + start_angle: f64, +} + +fn revolve_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| { + Mutex::new(RevolveDefaults { + mode: ExtrudeMode::Solid, + angle: std::f64::consts::TAU, + start_angle: 0.0, + }) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] enum RevolveStep { Pick, + Mode, AxisStart, AxisEnd, + AxisObject, Angle, + StartAngle, + Expression, } impl RevolveCommand { - pub fn new(color: [f32; 4]) -> Self { + pub fn new(color: [f32; 4], isolines: usize) -> Self { + let defaults = *revolve_defaults().lock().unwrap_or_else(|error| error.into_inner()); Self { step: RevolveStep::Pick, - target_handle: acadrust::Handle::NULL, + handles: Vec::new(), + preview_profiles: Vec::new(), + injected_axis: None, axis_start: DVec3::ZERO, axis_end: DVec3::new(0.0, 0.0, 1.0), + working_plane: WorkingPlane::default(), + mode: defaults.mode, + mode_return: RevolveStep::Pick, + angle: defaults.angle, + start_angle: defaults.start_angle, + reverse: false, + isolines, color, } } + + pub fn set_preselection(&mut self, profiles: Vec<(Handle, EntityType)>) { + let selected_handles = profiles.iter().map(|(handle, _)| *handle).collect::>(); + self.preview_profiles = profiles + .into_iter() + .filter(|(_, entity)| { + crate::scene::model::sweep_model::extrusion_profile_of(entity).is_some() + }) + .collect(); + self.handles = if self.preview_profiles.is_empty() { + Vec::new() + } else { + selected_handles + }; + if !self.handles.is_empty() { + self.step = RevolveStep::AxisStart; + } + } + + fn profile_reference(&self) -> Option { + let axis = (self.axis_end - self.axis_start).try_normalize()?; + self.preview_profiles + .iter() + .filter_map(|(_, entity)| { + crate::scene::model::sweep_model::extrusion_profile_of(entity) + }) + .flat_map(|(profile, _)| { + profile.pieces.into_iter().flat_map(move |piece| { + [0.0, 0.5, 1.0].into_iter().map(move |parameter| { + DVec3::from_array(profile.plane.point_at(piece.point_at(parameter))) + }) + }) + }) + .filter(|point| point.is_finite()) + .max_by(|first, second| { + let radial_length = |point: DVec3| { + let delta = point - self.axis_start; + (delta - axis * delta.dot(axis)).length_squared() + }; + radial_length(*first).total_cmp(&radial_length(*second)) + }) + } + + fn angle_anchor(&self) -> Option { + let axis = (self.axis_end - self.axis_start).try_normalize()?; + let reference = self.profile_reference()?; + Some(self.axis_start + axis * (reference - self.axis_start).dot(axis)) + } + + fn cursor_angle(&self, cursor: DVec3, full_at_zero: bool) -> Option { + let axis = (self.axis_end - self.axis_start).try_normalize()?; + let anchor = self.angle_anchor()?; + let reference = (self.profile_reference()? - anchor).try_normalize()?; + let cursor_direction = cursor - anchor; + let radial = (cursor_direction - axis * cursor_direction.dot(axis)).try_normalize()?; + let mut angle = axis + .dot(reference.cross(radial)) + .atan2(reference.dot(radial)); + if angle < 0.0 { + angle += std::f64::consts::TAU; + } + if full_at_zero && angle.abs() <= 1e-9 { + angle = std::f64::consts::TAU; + } + angle.is_finite().then_some(angle) + } + + fn signed_angle(&self, angle: f64) -> f64 { + angle * if self.reverse { -1.0 } else { 1.0 } + } + + fn preview_wires(&self, cursor: DVec3) -> Vec { + let (angle, start_angle) = match self.step { + RevolveStep::Angle => ( + self.cursor_angle(cursor, true) + .map(|angle| self.signed_angle(angle)), + Some(self.start_angle), + ), + RevolveStep::StartAngle => ( + Some(self.signed_angle(self.angle)), + self.cursor_angle(cursor, false), + ), + _ => return Vec::new(), + }; + let (Some(angle), Some(start_angle)) = (angle, start_angle) else { + return Vec::new(); + }; + let from = self.axis_start.to_array(); + let to = self.axis_end.to_array(); + self.preview_profiles + .iter() + .flat_map(|(_, entity)| { + let Some((_, closed)) = + crate::scene::model::sweep_model::extrusion_profile_of(entity) + else { + return Vec::new(); + }; + let creates_surface = self.mode == ExtrudeMode::Surface || !closed; + let body = if creates_surface { + crate::scene::model::sweep_model::revolved_surface( + entity, + from, + to, + angle, + start_angle, + ) + } else { + crate::scene::model::sweep_model::revolved( + entity, + from, + to, + angle, + start_angle, + ) + }; + body.map(|body| preview_body_wires(&body, self.color, self.isolines)) + .unwrap_or_default() + }) + .collect() + } + + fn set_axis(&mut self, direction: DVec3) -> CmdResult { + let Some(direction) = direction.try_normalize() else { + return CmdResult::NeedPoint; + }; + self.axis_start = self.working_plane.origin; + self.axis_end = self.axis_start + direction; + self.step = RevolveStep::Angle; + CmdResult::NeedPoint + } + + fn finish(&self, angle: f64) -> CmdResult { + let angle = self.signed_angle(angle); + if angle.is_finite() && angle.abs() > 1e-9 && angle.abs() <= std::f64::consts::TAU + 1e-9 { + let mut defaults = revolve_defaults().lock().unwrap_or_else(|error| error.into_inner()); + defaults.mode = self.mode; + defaults.angle = angle.abs(); + defaults.start_angle = self.start_angle; + } + CmdResult::RevolveEntities { + handles: self.handles.clone(), + axis_start: self.axis_start, + axis_end: self.axis_end, + angle, + start_angle: self.start_angle, + mode: self.mode, + color: self.color, + } + } +} + +fn revolve_axis(entity: &EntityType) -> Option<(DVec3, DVec3)> { + let point = |value: &acadrust::types::Vector3| DVec3::new(value.x, value.y, value.z); + let (start, direction) = match entity { + EntityType::Line(line) => (point(&line.start), point(&line.end) - point(&line.start)), + EntityType::Ray(ray) => (point(&ray.base_point), point(&ray.direction)), + EntityType::XLine(line) => (point(&line.base_point), point(&line.direction)), + EntityType::Polyline3D(line) if line.vertices.len() == 2 => { + let start = point(&line.vertices[0].position); + (start, point(&line.vertices[1].position) - start) + } + _ => return None, + }; + let direction = direction.try_normalize()?; + Some((start, start + direction)) } impl CadCommand for RevolveCommand { @@ -647,22 +873,70 @@ impl CadCommand for RevolveCommand { } fn prompt(&self) -> String { match self.step { - RevolveStep::Pick => t!("REVOLVE Select profile:").into_owned(), - RevolveStep::AxisStart => t!("REVOLVE Axis start point:").into_owned(), + RevolveStep::Pick => t!("REVOLVE Select objects to revolve or [Mode] (Enter to finish):").into_owned(), + RevolveStep::Mode => format!( + "{} <{}>:", + t!("REVOLVE Creation mode [Solid/Surface]"), + if self.mode == ExtrudeMode::Solid { "Solid" } else { "Surface" } + ), + RevolveStep::AxisStart => t!("REVOLVE Specify axis start point or [Object/X/Y/Z/Mode]:").into_owned(), RevolveStep::AxisEnd => t!("REVOLVE Axis end point:").into_owned(), - RevolveStep::Angle => t!("REVOLVE Angle of revolution <360>:").into_owned(), + RevolveStep::AxisObject => t!("REVOLVE Select line, ray, or construction line for axis:").into_owned(), + RevolveStep::Angle => format!( + "{} <{}>:", + t!("REVOLVE Specify angle of revolution or [Start angle/Reverse/Expression]"), + crate::entities::common::format_angle(self.angle) + ), + RevolveStep::StartAngle => format!( + "{} <{}>:", + t!("REVOLVE Specify start angle"), + crate::entities::common::format_angle(self.start_angle) + ), + RevolveStep::Expression => t!("REVOLVE Enter angle expression:").into_owned(), + } + } + fn options(&self) -> Vec { + match self.step { + RevolveStep::Pick => vec![CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + RevolveStep::Mode => vec![ + CmdOption::new("Solid", "SOLID"), + CmdOption::new("Surface", "SURFACE"), + ], + RevolveStep::AxisStart => vec![ + CmdOption::new("Object", "OBJECT"), + CmdOption::new("X", "X"), + CmdOption::new("Y", "Y"), + CmdOption::new("Z", "Z"), + CmdOption::new("Mode", "MODE"), + ], + RevolveStep::Angle => vec![ + CmdOption::new("Start angle", "START"), + CmdOption::new("Reverse", "REVERSE"), + CmdOption::new("Expression", "EXPRESSION"), + ], + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.step == RevolveStep::Pick + self.step == RevolveStep::AxisObject } fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - self.target_handle = handle; - self.step = RevolveStep::AxisStart; - CmdResult::NeedPoint + match self.step { + RevolveStep::AxisObject => { + let Some((start, end)) = self.injected_axis.take().as_ref().and_then(revolve_axis) + else { + return CmdResult::NeedPoint; + }; + self.axis_start = start; + self.axis_end = end; + self.step = RevolveStep::Angle; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { @@ -672,46 +946,235 @@ impl CadCommand for RevolveCommand { CmdResult::NeedPoint } RevolveStep::AxisEnd => { - self.axis_end = pt; + if (pt - self.axis_start).length_squared() <= 1e-12 { + CmdResult::NeedPoint + } else { + self.axis_end = pt; + self.step = RevolveStep::Angle; + CmdResult::NeedPoint + } + } + RevolveStep::Angle => self + .cursor_angle(pt, true) + .map(|angle| self.finish(angle)) + .unwrap_or(CmdResult::NeedPoint), + RevolveStep::StartAngle => { + let Some(angle) = self.cursor_angle(pt, false) else { + return CmdResult::NeedPoint; + }; + self.start_angle = angle; self.step = RevolveStep::Angle; CmdResult::NeedPoint } - RevolveStep::Angle => self.make_revolve(360.0), _ => CmdResult::NeedPoint, } } fn wants_text_input(&self) -> bool { - self.step == RevolveStep::Angle + matches!( + self.step, + RevolveStep::Pick + | RevolveStep::Mode + | RevolveStep::AxisStart + | RevolveStep::Angle + | RevolveStep::StartAngle + | RevolveStep::Expression + ) + } + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, RevolveStep::AxisStart | RevolveStep::Angle) } fn on_text_input(&mut self, text: &str) -> Option { - let angle = if text.trim().is_empty() { - 360.0f32 - } else { - text.trim() - .parse::() - .ok() - .filter(|&a| a.abs() > 1e-3)? - }; - Some(self.make_revolve(angle)) + let value = text.trim(); + if value.is_empty() { + return None; + } + let upper = value.to_ascii_uppercase(); + match self.step { + RevolveStep::Pick if "MODE".starts_with(&upper) => { + self.mode_return = RevolveStep::Pick; + self.step = RevolveStep::Mode; + Some(CmdResult::NeedPoint) + } + RevolveStep::Mode => { + if "SOLID".starts_with(&upper) { + self.mode = ExtrudeMode::Solid; + } else if "SURFACE".starts_with(&upper) { + self.mode = ExtrudeMode::Surface; + } else { + return Some(CmdResult::NeedPoint); + } + revolve_defaults() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .mode = self.mode; + self.step = self.mode_return; + Some(CmdResult::NeedPoint) + } + RevolveStep::AxisStart => { + if "OBJECT".starts_with(&upper) { + self.step = RevolveStep::AxisObject; + Some(CmdResult::NeedPoint) + } else if "MODE".starts_with(&upper) { + self.mode_return = RevolveStep::AxisStart; + self.step = RevolveStep::Mode; + Some(CmdResult::NeedPoint) + } else if upper == "X" { + Some(self.set_axis(self.working_plane.x)) + } else if upper == "Y" { + Some(self.set_axis(self.working_plane.y)) + } else if upper == "Z" { + Some(self.set_axis(self.working_plane.z)) + } else { + None + } + } + RevolveStep::Angle => { + if "START ANGLE".starts_with(&upper) || "START".starts_with(&upper) { + self.step = RevolveStep::StartAngle; + return Some(CmdResult::NeedPoint); + } + if "REVERSE".starts_with(&upper) { + self.reverse = !self.reverse; + return Some(CmdResult::NeedPoint); + } + if "EXPRESSION".starts_with(&upper) { + self.step = RevolveStep::Expression; + return Some(CmdResult::NeedPoint); + } + crate::entities::common::parse_angle(value) + .filter(|angle| { + angle.is_finite() + && angle.abs() > 1e-9 + && angle.abs() <= std::f64::consts::TAU + 1e-9 + }) + .map(|angle| self.finish(angle)) + } + RevolveStep::StartAngle => { + let angle = crate::entities::common::parse_angle(value)?; + if !angle.is_finite() { + return Some(CmdResult::NeedPoint); + } + self.start_angle = angle; + self.step = RevolveStep::Angle; + Some(CmdResult::NeedPoint) + } + RevolveStep::Expression => { + let angle = crate::app::expr_eval::eval_number(value) + .and_then(|value| crate::entities::common::parse_angle(&value.to_string()))?; + if !angle.is_finite() + || angle.abs() <= 1e-9 + || angle.abs() > std::f64::consts::TAU + 1e-9 + { + return Some(CmdResult::NeedPoint); + } + Some(self.finish(angle)) + } + _ => None, + } } fn on_enter(&mut self) -> CmdResult { - if self.step == RevolveStep::Angle { - self.make_revolve(360.0) - } else { - CmdResult::Cancel + match self.step { + RevolveStep::Pick if !self.handles.is_empty() => { + self.step = RevolveStep::AxisStart; + CmdResult::NeedPoint + } + RevolveStep::Mode => { + self.step = self.mode_return; + CmdResult::NeedPoint + } + RevolveStep::Angle => self.finish(self.angle), + RevolveStep::StartAngle => { + self.step = RevolveStep::Angle; + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, } } -} - -impl RevolveCommand { - fn make_revolve(&self, angle_deg: f32) -> CmdResult { - CmdResult::RevolveEntity { - handle: self.target_handle, - axis_start: self.axis_start, - axis_end: self.axis_end, - angle_deg, - color: self.color, + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.working_plane = plane; + } + fn is_selection_gathering(&self) -> bool { + self.step == RevolveStep::Pick + } + fn selection_forces_add(&self) -> bool { + self.step == RevolveStep::Pick + } + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step != RevolveStep::Pick { + return; } + let selected_handles = entities.iter().map(|entry| entry.handle).collect::>(); + self.preview_profiles = entities + .into_iter() + .filter(|entry| { + crate::scene::model::sweep_model::extrusion_profile_of(&entry.entity).is_some() + }) + .map(|entry| (entry.handle, entry.entity)) + .collect(); + self.handles = if self.preview_profiles.is_empty() { + Vec::new() + } else { + selected_handles + }; + } + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + fn inject_before_entity_pick(&self) -> bool { + self.step == RevolveStep::AxisObject + } + fn inject_picked_entity(&mut self, entity: EntityType) { + if self.step == RevolveStep::AxisObject { + self.injected_axis = Some(entity); + } + } + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + self.preview_wires(cursor) + } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.step, RevolveStep::Angle | RevolveStep::StartAngle) { + crate::command::DynField::Angle + } else { + crate::command::DynField::Point + } + } + fn dyn_spec(&self) -> Option { + matches!(self.step, RevolveStep::Angle | RevolveStep::StartAngle).then(|| { + crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point( + self.angle_anchor().unwrap_or(self.axis_start), + ), + fields: vec![crate::command::DynFieldSpec::new(crate::command::DynRole::Angle)], + guide: crate::command::DynGuide::None, + ref_point: self.profile_reference(), + } + }) + } + fn dyn_commit_as_text(&self) -> bool { + matches!(self.step, RevolveStep::Angle | RevolveStep::StartAngle) + } + fn dyn_auto_sign_angle(&self) -> bool { + false + } + fn cursor_plane(&self) -> Option<(DVec3, DVec3)> { + if !matches!(self.step, RevolveStep::Angle | RevolveStep::StartAngle) { + return None; + } + Some(( + (self.axis_end - self.axis_start).try_normalize()?, + self.angle_anchor()?, + )) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let full_at_zero = self.step == RevolveStep::Angle; + self.cursor_angle(cursor, full_at_zero).map(|angle| { + let angle = if self.step == RevolveStep::Angle { + self.signed_angle(angle) + } else { + angle + }; + crate::command::dyn_display_angle_deg(angle as f32) as f64 + }) } } @@ -868,10 +1331,79 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType EntityType::Surface(surface) } +pub fn empty_revolved_surface( + profile: &EntityType, + axis_start: DVec3, + axis_end: DVec3, + angle: f64, + start_angle: f64, +) -> EntityType { + use acadrust::entities::{Surface, SurfaceData, SurfaceKind}; + use acadrust::types::Vector3; + + let mut surface = Surface::new(SurfaceKind::Revolved); + let axis = (axis_end - axis_start).normalize_or(DVec3::Z); + surface.point_of_reference = Vector3::new(axis_start.x, axis_start.y, axis_start.z); + if let SurfaceData::Revolved { + revolve_entity, + axis_point, + axis_vector, + revolve_angle, + start_angle: stored_start, + entity_transform, + solid, + .. + } = &mut surface.surface_data + { + if let Some((embedded, transform)) = + crate::scene::model::sweep_model::embedded_revolve_profile(profile) + { + *revolve_entity = Some(embedded); + *entity_transform = transform; + } + *axis_point = Vector3::new(axis_start.x, axis_start.y, axis_start.z); + *axis_vector = Vector3::new(axis.x, axis.y, axis.z); + *revolve_angle = angle; + *stored_start = start_angle; + *solid = false; + } + EntityType::Surface(surface) +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE", "THICKEN", "PRESSPULL"] }); inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); + +#[cfg(test)] +mod revolve_tests { + use super::*; + + #[test] + fn cursor_angle_is_measured_about_the_model_axis() { + let profile = EntityType::Line(acadrust::entities::Line::from_coords( + 2.0, 0.0, 0.0, 2.0, 4.0, 0.0, + )); + let mut command = RevolveCommand::new([1.0; 4], 0); + command.set_preselection(vec![(Handle::new(1), profile)]); + command.axis_start = DVec3::ZERO; + command.axis_end = DVec3::Y; + command.step = RevolveStep::Angle; + + let anchor = command.angle_anchor().unwrap(); + let quarter = anchor - DVec3::Z * 2.0; + let angle = command.cursor_angle(quarter, false).unwrap(); + assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + + let displaced_along_axis = quarter + DVec3::Y * 100.0; + let angle = command.cursor_angle(displaced_along_axis, false).unwrap(); + assert!((angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + + let (normal, origin) = command.cursor_plane().unwrap(); + assert!((normal - DVec3::Y).length_squared() < 1e-24); + assert!((origin - anchor).length_squared() < 1e-24); + } +} inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0a2a3b917..053b4470e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2729,14 +2729,28 @@ impl Scene { } /// Cache and display a Model-tab kernel solid. - pub fn register_solid_model(&mut self, handle: Handle, solid: cadkernel::brep::Body) { + pub fn register_solid_model( + &mut self, + handle: Handle, + solid: cadkernel::brep::Body, + ) -> bool { let color = self .document .get_entity(handle) .map(|e| self.render_style(e).0) .unwrap_or([0.8, 0.8, 0.85, 1.0]); - if let Some((mut set, wires, center)) = - crate::scene::model::solid_model::display_from_solid(&solid, color) + 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 displayed = if let Some((mut set, wires, center)) = + crate::scene::model::solid_model::display_from_solid( + &solid, + color, + facet_resolution, + chordal_deflection, + isolines, + ) { if let Some(entity) = self.document.get_entity_mut(handle) { let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); @@ -2778,15 +2792,18 @@ impl Scene { ); } self.meshes.insert(handle, set); + true } else { self.meshes.remove(&handle); - } + false + }; self.solid_models.insert(handle, solid); self.sync_solid_reference_point(handle); // Only this solid's mesh changed — report just its handle so the mesh / // wire caches patch it in rather than rebuilding the whole drawing (and // so a bulk register loop stays O(n), not O(n²)). self.bump_entities(&[(handle, ChangeKind::Modified)]); + displayed } /// `BACKGROUND` change picks up the new `adapt_to_bg` result without diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 31d75a372..b631f02b6 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -68,6 +68,13 @@ 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_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"; +pub const PROP_AXIS_POSITION_Y: &str = "solid_history_axis_position_y"; +pub const PROP_AXIS_POSITION_Z: &str = "solid_history_axis_position_z"; +pub const PROP_AXIS_DIRECTION_X: &str = "solid_history_axis_direction_x"; +pub const PROP_AXIS_DIRECTION_Y: &str = "solid_history_axis_direction_y"; +pub const PROP_AXIS_DIRECTION_Z: &str = "solid_history_axis_direction_z"; pub const PROP_PYRAMID_TYPE: &str = "solid_history_pyramid_type"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; @@ -106,6 +113,73 @@ fn displayed_history_state(object_show_history: bool, show_history_mode: i16) -> } } +fn revolve_profile_world_normal(value: &SolidHistoryRevolve) -> Option { + let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; + let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; + let inverse_transpose = matrix(value.base.transform)?.inverse().transpose(); + inverse_transpose + .transform_vector3(glam::DVec3::from_array(normal)) + .try_normalize() +} + +fn revolve_position_component_editable(value: &SolidHistoryRevolve, axis: usize) -> bool { + revolve_profile_world_normal(value).is_some_and(|normal| normal[axis].abs() <= 1e-9) +} + +fn revolve_direction_component_capacity( + value: &SolidHistoryRevolve, + axis: usize, +) -> Option<(glam::DVec3, f64)> { + let normal = revolve_profile_world_normal(value)?; + let capacity = (1.0 - normal[axis] * normal[axis]).max(0.0).sqrt(); + Some((normal, capacity)) +} + +fn revolve_direction_component_editable(value: &SolidHistoryRevolve, axis: usize) -> bool { + revolve_direction_component_capacity(value, axis) + .is_some_and(|(_, capacity)| capacity > 1e-9) +} + +fn revolve_position_property( + value: &SolidHistoryRevolve, + axis: usize, + label: &str, + field: &'static str, + position: f64, +) -> Property { + if revolve_position_component_editable(value, axis) { + crate::entities::common::edit_prop(label, field, position) + } else { + Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(crate::entities::common::format_length(position)), + } + } +} + +fn revolve_direction_property( + value: &SolidHistoryRevolve, + axis: usize, + label: &str, + field: &'static str, + direction: f64, +) -> Property { + if revolve_direction_component_editable(value, axis) { + crate::entities::common::edit_scalar_prop(label, field, direction) + } else { + Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(if direction == 0.0 { + "0".to_string() + } else { + direction.to_string() + }), + } + } +} + pub fn has_specialized_primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -122,6 +196,7 @@ pub fn has_specialized_primitive_properties( | SolidHistoryOperation::Pyramid(_) | SolidHistoryOperation::Sweep(_) | SolidHistoryOperation::Extrusion(_) + | SolidHistoryOperation::Revolve(_) ) ) } @@ -147,10 +222,124 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option ), SolidHistoryOperation::Torus(value) => world_point(value.base.transform, [0.0; 3]), SolidHistoryOperation::Pyramid(value) => world_point(value.base.transform, [0.0; 3]), + SolidHistoryOperation::Revolve(value) => world_point( + value.base.transform, + [value.axis_point.x, value.axis_point.y, value.axis_point.z], + ), _ => None, } } +fn revolve_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryRevolve, +) -> Vec { + let Some(axis_position) = world_point( + value.base.transform, + [value.axis_point.x, value.axis_point.y, value.axis_point.z], + ) else { + return Vec::new(); + }; + let Some(axis_direction) = world_vector( + value.base.transform, + [value.direction.x, value.direction.y, value.direction.z], + ) else { + return Vec::new(); + }; + 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 show_value = if show_history { "Yes" } else { "No" }; + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("Revolve").into_owned()), + }, + Property { + label: t!("Angle of revolution").into_owned(), + field: PROP_REVOLVE_ANGLE, + value: PropValue::EditText(crate::entities::common::format_angle( + value.revolve_angle, + )), + }, + revolve_position_property( + value, + 0, + t!("Axis position X").as_ref(), + PROP_AXIS_POSITION_X, + axis_position.x, + ), + revolve_position_property( + value, + 1, + t!("Axis position Y").as_ref(), + PROP_AXIS_POSITION_Y, + axis_position.y, + ), + revolve_position_property( + value, + 2, + t!("Axis position Z").as_ref(), + PROP_AXIS_POSITION_Z, + axis_position.z, + ), + revolve_direction_property( + value, + 0, + t!("Axis direction X").as_ref(), + PROP_AXIS_DIRECTION_X, + axis_direction.x, + ), + revolve_direction_property( + value, + 1, + t!("Axis direction Y").as_ref(), + PROP_AXIS_DIRECTION_Y, + axis_direction.y, + ), + revolve_direction_property( + value, + 2, + t!("Axis direction Z").as_ref(), + PROP_AXIS_DIRECTION_Z, + axis_direction.z, + ), + ], + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: show_value.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(show_value.to_string()) + }, + }, + ], + }, + ] +} + fn extrusion_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -1021,6 +1210,7 @@ pub fn primitive_properties( SolidHistoryOperation::Extrusion(value) => { extrusion_properties(document, handle, value) } + SolidHistoryOperation::Revolve(value) => revolve_properties(document, handle, value), _ => Vec::new(), } } @@ -1053,6 +1243,13 @@ pub fn is_primitive_property(field: &str) -> bool { | PROP_SCALE_ALONG_PATH | PROP_EXTRUSION_HEIGHT | PROP_TAPER_ANGLE + | PROP_REVOLVE_ANGLE + | PROP_AXIS_POSITION_X + | PROP_AXIS_POSITION_Y + | PROP_AXIS_POSITION_Z + | PROP_AXIS_DIRECTION_X + | PROP_AXIS_DIRECTION_Y + | PROP_AXIS_DIRECTION_Z | PROP_PYRAMID_TYPE ) } @@ -1142,6 +1339,117 @@ fn apply_extrusion_geometry_property( } } +fn apply_revolve_geometry_property( + value: &mut SolidHistoryRevolve, + field: &str, + text: &str, +) -> Option { + if field == PROP_REVOLVE_ANGLE { + let angle = crate::entities::common::parse_angle(text)?; + if !angle.is_finite() + || angle.abs() <= 1e-12 + || angle.abs() > std::f64::consts::TAU + 1e-12 + || (angle - value.revolve_angle).abs() <= 1e-12 + { + return Some(false); + } + value.revolve_angle = angle; + return Some(true); + } + + if matches!( + field, + PROP_AXIS_POSITION_X | PROP_AXIS_POSITION_Y | PROP_AXIS_POSITION_Z + ) { + let axis = match field { + PROP_AXIS_POSITION_X => 0, + PROP_AXIS_POSITION_Y => 1, + _ => 2, + }; + if !revolve_position_component_editable(value, axis) { + return Some(false); + } + let target = crate::entities::common::parse_length(text)?; + if !target.is_finite() { + return Some(false); + } + let mut world = world_point( + value.base.transform, + [value.axis_point.x, value.axis_point.y, value.axis_point.z], + )?; + if (world[axis] - target).abs() <= 1e-12 { + return Some(false); + } + world[axis] = target; + let local = local_point(value.base.transform, world)?; + if !local.is_finite() { + return Some(false); + } + value.axis_point = acadrust::types::Vector3::new(local.x, local.y, local.z); + return Some(true); + } + + if matches!( + field, + PROP_AXIS_DIRECTION_X | PROP_AXIS_DIRECTION_Y | PROP_AXIS_DIRECTION_Z + ) { + let axis = match field { + PROP_AXIS_DIRECTION_X => 0, + PROP_AXIS_DIRECTION_Y => 1, + _ => 2, + }; + let target = text.trim().replace(',', ".").parse::().ok()?; + let Some((normal, capacity)) = revolve_direction_component_capacity(value, axis) else { + return Some(false); + }; + if !target.is_finite() || capacity <= 1e-9 || target.abs() > capacity + 1e-12 { + return Some(false); + } + let current_world = world_vector( + value.base.transform, + [value.direction.x, value.direction.y, value.direction.z], + )?; + let target = target.clamp(-capacity, capacity); + if (current_world[axis] - target).abs() <= 1e-12 { + return Some(false); + } + let component_axis = match axis { + 0 => glam::DVec3::X, + 1 => glam::DVec3::Y, + _ => glam::DVec3::Z, + }; + let Some(projected_axis) = (component_axis - normal * normal[axis]).try_normalize() else { + return Some(false); + }; + let other_axis = normal.cross(projected_axis); + let along_projected = target / capacity; + let remaining = (1.0 - along_projected * along_projected).max(0.0).sqrt(); + let other_sign = if current_world.dot(other_axis) < 0.0 { + -1.0 + } else { + 1.0 + }; + let world = + projected_axis * along_projected + other_axis * (other_sign * remaining); + let Some(local) = matrix(value.base.transform)? + .inverse() + .transform_vector3(world) + .try_normalize() + else { + return Some(false); + }; + let current = glam::DVec3::new(value.direction.x, value.direction.y, value.direction.z) + .normalize_or_zero(); + if (local - current).length_squared() <= 1e-24 { + return Some(false); + } + value.direction = acadrust::types::Vector3::new(local.x, local.y, local.z); + return Some(true); + } + + None +} + pub fn apply_history_choice( document: &mut acadrust::CadDocument, handle: acadrust::Handle, @@ -1578,6 +1886,11 @@ pub fn apply_primitive_property( return applied; } } + if let SolidHistoryOperation::Revolve(revolve_value) = operation { + if let Some(applied) = apply_revolve_geometry_property(revolve_value, field, value) { + return applied; + } + } if let SolidHistoryOperation::Torus(torus_value) = operation { if let Some(applied) = apply_torus_position_property(torus_value, field, value) { return applied; diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index a439e43cb..24969c146 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -18,6 +18,29 @@ fn tessellation(body: &Body) -> brep::mesh::BodyMesh { ) } +fn display_tessellation( + body: &Body, + facet_resolution: f64, + chordal_deflection: Option, + isolines: usize, +) -> brep::mesh::BodyMesh { + let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { + facet_resolution.clamp(0.01, 10.0) + } else { + 1.0 + }; + let max_angle = chordal_deflection.map_or_else( + || cadkernel::tessellation::angle_for_resolution(resolution), + |_| cadkernel::tessellation::display_angle_for_resolution(resolution), + ); + let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) + .with_isolines(isolines); + if let Some(deflection) = chordal_deflection { + tolerance = tolerance.with_chordal_deflection(deflection); + } + brep::mesh::tessellate(body, tolerance) +} + /// Axis-aligned box from its center and full extents. pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option { brep::make::cuboid( @@ -397,8 +420,13 @@ fn mesh_from_tessellation( color, selected: false, }); - for edge in tessellation.edges { - for segment in edge.positions.windows(2) { + for positions in tessellation + .edges + .into_iter() + .map(|edge| edge.positions) + .chain(tessellation.isolines.into_iter().map(|line| line.positions)) + { + for segment in positions.windows(2) { for point in segment { let high = [point[0] as f32, point[1] as f32, point[2] as f32]; set.edge_verts.push(high); @@ -418,9 +446,17 @@ fn mesh_from_tessellation( pub fn display_from_solid( body: &Body, color: [f32; 4], + facet_resolution: f64, + chordal_deflection: Option, + isolines: usize, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; - let tessellation = tessellation(body); + let tessellation = display_tessellation( + body, + facet_resolution, + chordal_deflection, + isolines, + ); let center = mesh_center(&tessellation.mesh)?; let wires = tessellation .edges @@ -496,7 +532,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0]) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index cbc223513..6979b9c2e 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -386,15 +386,65 @@ pub fn revolved( from: [f64; 3], to: [f64; 3], angle: f64, + start_angle: f64, ) -> Option { - let profile = profile_of(entity)?; - brep::revolve( - profile.plane, - &profile.pieces, - from, - [to[0] - from[0], to[1] - from[1], to[2] - from[2]], - angle, - ) + let axis = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let body = if let Some((plane, profiles)) = + region_profiles(entity).filter(|(_, profiles)| profiles.len() > 1) + { + brep::revolve_region(plane, &profiles, from, axis, angle)? + } else { + let profile = profile_of(entity)?; + brep::revolve(profile.plane, &profile.pieces, from, axis, angle)? + }; + turn_revolved_body(body, from, axis, start_angle) +} + +/// REVOLVE sheet mode for an open or closed planar profile. +pub fn revolved_surface( + entity: &EntityType, + from: [f64; 3], + to: [f64; 3], + angle: f64, + start_angle: f64, +) -> Option { + let axis = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let body = if let Some((plane, profiles)) = + region_profiles(entity).filter(|(_, profiles)| profiles.len() > 1) + { + brep::revolve_surface_region(plane, &profiles, from, axis, angle)? + } else { + let (profile, _) = extrusion_profile_of(entity)?; + brep::revolve_surface(profile.plane, &profile.pieces, from, axis, angle)? + }; + turn_revolved_body(body, from, axis, start_angle) +} + +fn turn_revolved_body( + body: Body, + pivot: [f64; 3], + axis: [f64; 3], + angle: f64, +) -> Option { + if !angle.is_finite() { + return None; + } + if angle.abs() <= 1e-12 { + return Some(body); + } + let axis = Vec3::from(axis).normalize()?; + let rotate = |vector: Vec3| { + let (sin, cos) = angle.sin_cos(); + vector * cos + axis.cross(vector) * sin + axis * axis.dot(vector) * (1.0 - cos) + }; + let pivot = Vec3::from(pivot); + let placement = brep::Placement { + x_axis: rotate(Vec3::X).to_array(), + y_axis: rotate(Vec3::Y).to_array(), + z_axis: rotate(Vec3::Z).to_array(), + origin: (pivot - rotate(pivot)).to_array(), + }; + brep::transform(&body, &placement) } fn profile_center(profile: &Profile) -> Option { @@ -413,8 +463,13 @@ pub fn revolve_history( from: [f64; 3], to: [f64; 3], angle: f64, + start_angle: f64, ) -> Option { - if !angle.is_finite() || angle.abs() <= 1e-12 { + if !angle.is_finite() + || angle.abs() <= 1e-12 + || !start_angle.is_finite() + || matches!(entity, EntityType::Region(region) if region.wires.len() > 1) + { return None; } let profile = profile_of(entity)?; @@ -422,16 +477,27 @@ pub fn revolve_history( let direction = (Vec3::from(to) - from).normalize()?; let center = profile_center(&profile)?; let axis_point = from + direction * (center - from).dot(direction); + let (sweep_entity, transform) = embedded_planar_entity(entity)?; + let transform = glam::DMat4::from_cols_array(&transform); + let inverse = transform.inverse(); + if !transform.is_finite() || !inverse.is_finite() || transform.determinant().abs() <= 1e-12 { + return None; + } + let axis_point = inverse.transform_point3(glam::DVec3::from_array(axis_point.to_array())); + let direction = inverse + .transform_vector3(glam::DVec3::from_array(direction.to_array())) + .try_normalize()?; let mut base = SolidHistoryNodeBase::new(1); - base.transform = glam::DMat4::IDENTITY.to_cols_array(); + base.transform = transform.to_cols_array(); Some(SolidHistoryOperation::Revolve(SolidHistoryRevolve { base, operation_major: 1, axis_point: Vector3::new(axis_point.x, axis_point.y, axis_point.z), direction: Vector3::new(direction.x, direction.y, direction.z), revolve_angle: angle, + start_angle, flag_290: true, - sweep_entity: Some(embedded_path(entity)?), + sweep_entity: Some(sweep_entity), ..SolidHistoryRevolve::default() })) } @@ -597,6 +663,10 @@ fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; Some((EmbeddedEntity::LwPolyline(polyline), transform)) } +pub fn embedded_revolve_profile(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { + embedded_planar_entity(entity) +} + pub fn extrusion_history( profile: &EntityType, path: Option<&EntityType>, @@ -850,6 +920,7 @@ mod tests { [0.0, 0.0, 0.0], [0.0, 10.0, 0.0], std::f64::consts::TAU, + 0.0, ); // The profile is in the XY plane and the axis lies in it, so this is // a revolution about the y axis: radius ten, height ten. @@ -875,7 +946,8 @@ mod tests { &square(10.0), [0.0, 0.0, 0.0], [0.0, 1.0, 1.0], - std::f64::consts::TAU + std::f64::consts::TAU, + 0.0, ) .is_none()); } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 723a016d8..f2cc33654 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -781,7 +781,19 @@ impl Scene { // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) { + self.remove_entities(handles, true); + } + /// Roll back entities created by the current command after a downstream + /// registration failure. These entities were never successful command + /// results, so locked-layer edit policy must not prevent their removal. + /// The shared removal path keeps first-touch undo state, history objects, + /// groups, selection, and derived caches coherent. + pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) { + self.remove_entities(handles, false); + } + + fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) { let erase_handles = self.handles_expanded_for_leader_annotations(handles); let mut handle_set: HashSet = HashSet::default(); @@ -791,7 +803,7 @@ impl Scene { for &h in &erase_handles { // Objects on a locked layer can't be erased. - if self.is_layer_locked(h) { + if respect_layer_locks && self.is_layer_locked(h) { continue; } // Delta-undo: capture the removed entity so an undo can re-insert it.