From d3033caf24f376877ffd73999e52b69e97893f7c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 7 Oct 2026 11:14:13 +0300 Subject: [PATCH] feat(solid history): booleans keep the solids they joined editable With SOLIDHIST on, UNION, SUBTRACT and INTERSECT no longer reduce the result's history to a frozen body: each solid's history joins the result's under a boolean step, in the order the result applied them. The Properties palette lists a composite's solids under Operand; the one chosen shows its own geometry rows and grips, placed where it sits in the composite, and editing it replays the whole tree. A composite read from a file keeps its saved body; the tree is replayed only on edits. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/model_ops.rs | 86 +++++++++++++++-- src/app/update/command.rs | 13 +++ src/entities/object_data.rs | 2 +- src/locale_catalog.rs | 1 + src/scene/entity.rs | 20 ++-- src/scene/model/solid_history.rs | 158 ++++++++++++++++++++++++++++--- src/scene/modify.rs | 134 +++++++++++++++++++++----- 28 files changed, 383 insertions(+), 52 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index b5088c6f6..207d05975 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2342,6 +2342,7 @@ common = .requires-two-positive-values = يتطلب قيمتين موجبتين (x,y) أو قيمة واحدة لكليهما. .snap-marker-color = لون علامة الالتقاط .command-line-text-color = لون نص سطر الأوامر + .operand = المعامل .linetypes-loaded = تم تحميل __ocs_fmt_0__ من أنواع الخطوط. .geometric-center-snap = المركز الهندسي .no-trim-option = بدون تشذيب diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 1d0073369..76e50e6fb 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Изискват се две положителни стойности (x,y) или една за двете. .snap-marker-color = Цвят на маркера за прихващане .command-line-text-color = Цвят на текста в командния ред + .operand = Операнд .linetypes-loaded = Заредени типове линии: __ocs_fmt_0__. .geometric-center-snap = Геометричен център .no-trim-option = Без подрязване diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ff14acdee..55c6df77f 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Vyžaduje dvě kladné hodnoty (x,y) nebo jednu pro obě. .snap-marker-color = Barva značky uchopení .command-line-text-color = Barva textu příkazového řádku + .operand = Operand .linetypes-loaded = Načteno typů čar: __ocs_fmt_0__. .geometric-center-snap = Geometrický střed .no-trim-option = Neoříznout diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f18f5edc7..2a5fe7c0c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Erfordert zwei positive Werte (x,y) oder einen für beide. .snap-marker-color = Farbe der Fangmarkierung .command-line-text-color = Textfarbe der Befehlszeile + .operand = Operand .linetypes-loaded = __ocs_fmt_0__ Linientyp(en) geladen. .geometric-center-snap = Geometrischer Mittelpunkt .no-trim-option = Nicht stutzen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index d2ab60b7d..dc7ad55fa 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Απαιτούνται δύο θετικές τιμές (x,y) ή μία και για τις δύο. .snap-marker-color = Χρώμα δείκτη έλξης .command-line-text-color = Χρώμα κειμένου γραμμής εντολών + .operand = Τελεστέος .linetypes-loaded = Φορτώθηκαν __ocs_fmt_0__ τύποι γραμμής. .geometric-center-snap = Γεωμετρικό κέντρο .no-trim-option = Χωρίς περικοπή diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 1df12baa1..98c360d3f 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Requires two positive values (x,y) or one for both. .snap-marker-color = Snap marker color .command-line-text-color = Command line text color + .operand = Operand .linetypes-loaded = __ocs_fmt_0__ linetype(s) loaded. .geometric-center-snap = Geometric Center .no-trim-option = No trim diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8c6199d4d..3dd133e1e 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Requiere dos valores positivos (x,y) o uno para ambos. .snap-marker-color = Color del marcador de referencia .command-line-text-color = Color del texto de la línea de comandos + .operand = Operando .linetypes-loaded = __ocs_fmt_0__ tipo(s) de línea cargado(s). .geometric-center-snap = Centro geométrico .no-trim-option = No recortar diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 777353a31..ca1f53d0d 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Vaatii kaksi positiivista arvoa (x,y) tai yhden molemmille. .snap-marker-color = Kohdistusmerkin väri .command-line-text-color = Komentorivin tekstin väri + .operand = Operandi .linetypes-loaded = Ladattu __ocs_fmt_0__ viivatyyppiä. .geometric-center-snap = Geometrinen keskipiste .no-trim-option = Ei trimmausta diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index e9f4f0cb4..3e046163f 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Nécessite deux valeurs positives (x,y) ou une seule pour les deux. .snap-marker-color = Couleur du marqueur d'accrochage .command-line-text-color = Couleur du texte de la ligne de commande + .operand = Opérande .linetypes-loaded = __ocs_fmt_0__ type(s) de ligne chargé(s). .geometric-center-snap = Centre géométrique .no-trim-option = Sans ajuster diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e13ced65d..0129b7989 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2318,6 +2318,7 @@ common = .requires-two-positive-values = दो धनात्मक मान (x,y) या दोनों के लिए एक मान आवश्यक है। .snap-marker-color = स्नैप मार्कर का रंग .command-line-text-color = कमांड लाइन टेक्स्ट रंग + .operand = ऑपरेंड .linetypes-loaded = __ocs_fmt_0__ लाइनटाइप लोड हुए। .geometric-center-snap = ज्यामितीय केंद्र .no-trim-option = ट्रिम नहीं diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ca1abb88d..b9a0ec5a1 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Két pozitív érték (x,y) vagy egy közös érték szükséges. .snap-marker-color = Illesztési jelölő színe .command-line-text-color = Parancssor szövegszíne + .operand = Operandus .linetypes-loaded = __ocs_fmt_0__ vonaltípus betöltve. .geometric-center-snap = Geometriai középpont .no-trim-option = Nincs vágás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index b5cc454e9..5ae18e248 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Richiede due valori positivi (x,y) o uno per entrambi. .snap-marker-color = Colore del marcatore di snap .command-line-text-color = Colore del testo della riga di comando + .operand = Operando .linetypes-loaded = __ocs_fmt_0__ tipo/i di linea caricato/i. .geometric-center-snap = Centro geometrico .no-trim-option = Non tagliare diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 7d02e9959..8091dc8af 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2320,6 +2320,7 @@ common = .requires-two-positive-values = 2つの正の値 (x,y)、または両方に共通の1つの値が必要です。 .snap-marker-color = スナップ マーカーの色 .command-line-text-color = コマンドラインの文字色 + .operand = オペランド .linetypes-loaded = __ocs_fmt_0__ 個の線種をロードしました。 .geometric-center-snap = 図心 .no-trim-option = トリムしない diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 2a61aa614..7d307f28f 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = 양수 값 두 개(x,y) 또는 둘 다에 쓸 값 하나가 필요합니다. .snap-marker-color = 스냅 마커 색상 .command-line-text-color = 명령줄 텍스트 색상 + .operand = 피연산자 .linetypes-loaded = 선종류 __ocs_fmt_0__개를 로드했습니다. .geometric-center-snap = 기하학적 중심 .no-trim-option = 자르지 않기 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index da2f69ba4..5837f0e64 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Vereist twee positieve waarden (x,y) of één voor beide. .snap-marker-color = Kleur van snapmarkering .command-line-text-color = Tekstkleur opdrachtregel + .operand = Operand .linetypes-loaded = __ocs_fmt_0__ lijntype(n) geladen. .geometric-center-snap = Geometrisch middelpunt .no-trim-option = Niet trimmen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index fc0c483bf..21465676d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = Wymaga dwóch wartości dodatnich (x,y) lub jednej dla obu. .snap-marker-color = Kolor znacznika przyciągania .command-line-text-color = Kolor tekstu wiersza poleceń + .operand = Operand .linetypes-loaded = Wczytano rodzaje linii: __ocs_fmt_0__. .geometric-center-snap = Środek geometryczny .no-trim-option = Bez przycinania diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index b2c2d9a73..d8f602be2 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2330,6 +2330,7 @@ common = .requires-two-positive-values = Requer dois valores positivos (x,y) ou um para ambos. .snap-marker-color = Cor do marcador de snap .command-line-text-color = Cor do texto da linha de comando + .operand = Operando .linetypes-loaded = __ocs_fmt_0__ tipo(s) de linha carregado(s). .geometric-center-snap = Centro geométrico .no-trim-option = Não aparar diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 86bc3212e..c99fde1ff 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2333,6 +2333,7 @@ common = .requires-two-positive-values = Требуются два положительных значения (x,y) или одно для обоих. .snap-marker-color = Цвет маркера привязки .command-line-text-color = Цвет текста командной строки + .operand = Операнд .linetypes-loaded = Загружено типов линий: __ocs_fmt_0__. .geometric-center-snap = Геометрический центр .no-trim-option = Без обрезки diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 436b5bebd..d101ed4cc 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2314,6 +2314,7 @@ common = .requires-two-positive-values = İki pozitif değer (x,y) ya da ikisi için tek değer gerekir. .snap-marker-color = Yakalama işaretçisi rengi .command-line-text-color = Komut satırı metin rengi + .operand = İşlenen .linetypes-loaded = __ocs_fmt_0__ çizgi tipi yüklendi. .geometric-center-snap = Geometrik merkez .no-trim-option = Budama yok diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 5216b4d44..bf5af0047 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2317,6 +2317,7 @@ common = .requires-two-positive-values = 需要两个正值 (x,y),或一个同时用于两者的值。 .snap-marker-color = 捕捉标记颜色 .command-line-text-color = 命令行文字颜色 + .operand = 操作对象 .linetypes-loaded = 已加载 __ocs_fmt_0__ 个线型。 .geometric-center-snap = 几何中心 .no-trim-option = 不修剪 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index bf4254fed..fd1098867 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2329,6 +2329,7 @@ common = .requires-two-positive-values = 需要兩個正值 (x,y),或一個同時用於兩者的值。 .snap-marker-color = 鎖點標記顏色 .command-line-text-color = 命令列文字顏色 + .operand = 運算元 .linetypes-loaded = 已載入 __ocs_fmt_0__ 個線型。 .geometric-center-snap = 幾何中心 .no-trim-option = 不修剪 diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index ca5fb2554..c1d2df732 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -4,7 +4,7 @@ use codec::{ entities::{ AcisData, EntityCommon, Region, Solid3D, Surface, SurfaceData, SurfaceKind, Wire, }, - objects::SolidHistoryOperation, + objects::{SolidHistoryBoolean, SolidHistoryOperation}, EntityType, Handle, }; use kernel::brep::{Body, EdgeKey, FaceKey}; @@ -1006,6 +1006,32 @@ impl super::OpenCADStudio { } /// Intersect compatible selected solids, Regions, and coplanar Surfaces. + /// Before a boolean replaces `retained` with `entity`: with SOLIDHIST on, + /// join the histories of the solids it combined (`steps`, in the order + /// the result applied them) into `retained`'s and hand that history to + /// `entity`; otherwise drop it. True when the history was kept. + fn join_boolean_history( + &mut self, + retained: Handle, + entity: &mut EntityType, + steps: &[(Handle, u8)], + bodies: &HashMap, + ) -> bool { + let scene = &mut self.tabs[self.active_tab].scene; + let joined = scene.document.header.record_solid_history + && scene.join_boolean_histories(retained, retained, steps, bodies); + if !joined { + scene.delete_solid_history(retained); + return false; + } + if let (EntityType::Solid3D(entity), Some(EntityType::Solid3D(current))) = + (entity, scene.document.get_entity(retained)) + { + entity.history_handle = current.history_handle; + } + true + } + pub(super) fn solid_intersect(&mut self) -> Task { let i = self.active_tab; let mut handles = self.selected_intersect_handles(); @@ -1139,11 +1165,17 @@ impl super::OpenCADStudio { match group.outcome { PreparedIntersectOutcome::Replace { retained, - entity, + mut entity, body, display, } => { - self.tabs[i].scene.delete_solid_history(retained); + let steps = group + .handles + .iter() + .filter(|handle| **handle != retained) + .map(|handle| (*handle, SolidHistoryBoolean::INTERSECT)) + .collect::>(); + let joined = self.join_boolean_history(retained, &mut entity, &steps, &bodies); if !self.tabs[i].scene.update_entity(entity) { self.command_line.push_error( crate::t!("INTERSECT: the retained object could not be updated.") @@ -1152,6 +1184,7 @@ impl super::OpenCADStudio { return Task::none(); } if record_history + && !joined && matches!( self.tabs[i].scene.document.get_entity(retained), Some(EntityType::Solid3D(_)) @@ -1328,11 +1361,37 @@ impl super::OpenCADStudio { let history = solid_history::brep_op(&group.body); let mut entity = Solid3D::new(); entity.common = group.common; - self.add_solid_model_preserving_style( + let handle = self.add_solid_model_preserving_style( EntityType::Solid3D(entity), group.body, history, - ) + ); + // The new solid's history becomes the joined histories + // of the solids it unites. + let record = self.tabs[i].scene.document.header.record_solid_history; + if !handle.is_null() && record { + let bodies = group + .handles + .iter() + .filter_map(|operand| { + let body = self.tabs[i].scene.solid_models.get(operand)?; + Some((*operand, body.clone())) + }) + .collect::>(); + let steps = group + .handles + .iter() + .skip(1) + .map(|operand| (*operand, SolidHistoryBoolean::UNION)) + .collect::>(); + let scene = &mut self.tabs[i].scene; + if !scene.join_boolean_histories(handle, group.handles[0], &steps, &bodies) { + if let Some(body) = scene.solid_models.get(&handle).cloned() { + scene.create_solid_history(handle, solid_history::brep_op(&body)); + } + } + } + handle } UnionEntityKind::Region => { let mut entity = Region::new(); @@ -1588,8 +1647,20 @@ impl super::OpenCADStudio { let mut retained = Vec::new(); let mut consumed = Vec::new(); for group in prepared { - if let Some((handle, entity, body, display)) = group.result { - self.tabs[i].scene.delete_solid_history(handle); + if let Some((handle, mut entity, body, display)) = group.result { + let steps = group + .bases + .iter() + .skip(1) + .map(|base| (*base, SolidHistoryBoolean::UNION)) + .chain( + group + .cutters + .iter() + .map(|cutter| (*cutter, SolidHistoryBoolean::SUBTRACT)), + ) + .collect::>(); + let joined = self.join_boolean_history(handle, &mut entity, &steps, &operand_bodies); if !self.tabs[i].scene.update_entity(entity) { self.command_line.push_error( crate::t!("SUBTRACT: the retained base object could not be updated.").as_ref(), @@ -1597,6 +1668,7 @@ impl super::OpenCADStudio { return Task::none(); } if record_history + && !joined && matches!( self.tabs[i].scene.document.get_entity(handle), Some(EntityType::Solid3D(_)) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 870692e7e..7f9b9faa7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2732,6 +2732,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.edit_choice_open = false; return Task::none(); } + if field == crate::scene::model::solid_history::PROP_OPERAND { + // Which solid of a composite to edit: palette state only. + for handle in &handles { + crate::scene::model::solid_history::select_operand( + &self.tabs[i].scene.document, + *handle, + &value, + ); + } + self.tabs[i].properties.edit_choice_open = false; + self.refresh_properties(); + return Task::none(); + } if crate::scene::model::solid_history::is_history_choice(field) { self.apply_property_op(i, "CHPROP", &handles, |app, handle| { if app.tabs[i].scene.is_layer_locked(handle) { diff --git a/src/entities/object_data.rs b/src/entities/object_data.rs index 3270bc545..1fd9a0514 100644 --- a/src/entities/object_data.rs +++ b/src/entities/object_data.rs @@ -149,7 +149,7 @@ fn entity_history_handle(entity: &EntityType) -> Option { .filter(|handle| handle.is_valid()) } -fn history_operation_name(operation: &SolidHistoryOperation) -> &'static str { +pub(crate) fn history_operation_name(operation: &SolidHistoryOperation) -> &'static str { match operation { SolidHistoryOperation::Unknown => "Unknown", SolidHistoryOperation::Box(_) => "Box", diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index f78d847b5..44d1553a5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4895,6 +4895,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Requires two positive values (x,y) or one for both." => Some(("common", "requires-two-positive-values")), "Snap marker color" => Some(("common", "snap-marker-color")), "Command line text color" => Some(("common", "command-line-text-color")), + "Operand" => Some(("common", "operand")), "{added} linetype(s) loaded." => Some(("common", "linetypes-loaded")), "Geometric Center" => Some(("common", "geometric-center-snap")), "No trim" => Some(("common", "no-trim-option")), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0d0f1e8d0..8bbf31323 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -756,11 +756,12 @@ impl Scene { .iter() .filter(|handle| !self.solid_models.contains_key(handle)) .filter_map(|&handle| { - let from_history = self - .document - .solid_history_operations(handle) - .and_then(|operations| kernel::acis::rebuild_history(&operations).ok()); - let body = from_history.or_else(|| match self.document.get_entity(handle) { + let tree = self.document.solid_history_tree(handle); + let rebuilt = || { + tree.as_ref() + .and_then(|tree| kernel::acis::rebuild_history_tree(tree).ok()) + }; + let stored = || match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } @@ -771,7 +772,14 @@ impl Scene { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } _ => None, - })?; + }; + // A composite's saved body is what its booleans produced; + // replaying them is for edits, not for reading the file. + let body = if tree.as_ref().is_some_and(|tree| tree.has_boolean()) { + stored().or_else(rebuilt) + } else { + rebuilt().or_else(stored) + }?; Some((handle, body)) }) .collect(); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index e8a1598d5..488c7a6c0 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -94,6 +94,112 @@ pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed"; pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; +/// Which of a composite's solids the palette and grips edit. +pub const PROP_OPERAND: &str = "solid_history_operand"; + +thread_local! { + /// The operand edited inside each composite solid, by history node id. + static EDIT_OPERANDS: std::cell::RefCell> = + std::cell::RefCell::new(rustc_hash::FxHashMap::default()); +} + +/// The solids a composite's booleans joined, as (node id, palette label) in +/// operand order. Empty for a solid with no boolean step. +pub fn composite_operands( + document: &codec::CadDocument, + handle: codec::Handle, +) -> Vec<(i32, String)> { + let Some(tree) = document + .solid_history_tree(handle) + .filter(|tree| tree.has_boolean()) + else { + return Vec::new(); + }; + tree.leaves() + .into_iter() + .enumerate() + .filter_map(|(index, operation)| { + let name = crate::entities::object_data::history_operation_name(operation); + Some((operation.base()?.node_id(), format!("{}: {}", index + 1, t!(name)))) + }) + .collect() +} + +/// Edit the operand the palette lists as `label` from now on. +pub fn select_operand(document: &codec::CadDocument, handle: codec::Handle, label: &str) { + if let Some((id, _)) = composite_operands(document, handle) + .into_iter() + .find(|(_, candidate)| candidate == label) + { + EDIT_OPERANDS.with(|operands| operands.borrow_mut().insert(handle, id)); + } +} + +/// The composite's history and the node id of the operand being edited: the +/// one last chosen, or the first. +fn selected_operand( + document: &codec::CadDocument, + handle: codec::Handle, +) -> Option<(codec::objects::SolidHistoryTree, i32)> { + let tree = document + .solid_history_tree(handle) + .filter(|tree| tree.has_boolean())?; + let ids = tree + .leaves() + .into_iter() + .filter_map(|operation| Some(operation.base()?.node_id())) + .collect::>(); + let chosen = EDIT_OPERANDS.with(|operands| operands.borrow().get(&handle).copied()); + let id = chosen.filter(|id| ids.contains(id)).or(ids.first().copied())?; + Some((tree, id)) +} + +/// The placement the steps above node `id` add to it: the product of their +/// transforms, outermost first. +fn ancestors_frame(tree: &codec::objects::SolidHistoryTree, id: i32) -> Option { + if tree.operation.base().is_some_and(|base| base.node_id() == id) { + return Some(glam::DMat4::IDENTITY); + } + let own = matrix(tree.operation.base()?.transform)?; + tree.operands + .iter() + .find_map(|operand| ancestors_frame(operand, id)) + .map(|inner| own * inner) +} + +/// The operand of a composite solid that the palette and grips edit, placed +/// where it sits in the composite. None for a solid with no boolean step. +pub fn edit_operand( + document: &codec::CadDocument, + handle: codec::Handle, +) -> Option { + let (tree, id) = selected_operand(document, handle)?; + let frame = ancestors_frame(&tree, id)?; + let mut operation = tree.find(id)?.operation.clone(); + let base = operation.base_mut()?; + base.transform = (frame * matrix(base.transform)?).to_cols_array(); + Some(operation) +} + +/// An operand from [`edit_operand`], edited where it sits, back in the frame +/// its composite stores it in. `operation` itself when the solid has no +/// boolean step. +pub fn operand_to_history( + document: &codec::CadDocument, + handle: codec::Handle, + mut operation: SolidHistoryOperation, +) -> Option { + let Some(tree) = document + .solid_history_tree(handle) + .filter(|tree| tree.has_boolean()) + else { + return Some(operation); + }; + let frame = ancestors_frame(&tree, operation.base()?.node_id())?; + let base = operation.base_mut()?; + base.transform = (frame.inverse() * matrix(base.transform)?).to_cols_array(); + Some(operation) +} pub const PROP_SURFACE_TYPE: &str = "surface_type"; pub const PROP_SURFACE_WIREFRAME_TYPE: &str = "srf_wireframe_type"; pub const PROP_SURFACE_U_ISOLINES: &str = "srf_u_isolines"; @@ -250,6 +356,9 @@ pub fn primitive_property_operation( document: &codec::CadDocument, handle: codec::Handle, ) -> Option { + if let Some(operand) = edit_operand(document, handle) { + return Some(operand); + } document .solid_history_operations(handle) .and_then(|operations| operations.into_iter().next()) @@ -1619,7 +1728,33 @@ pub fn primitive_properties( return brep_properties(document, handle); }; corner_frame(&mut operation); - match &operation { + let mut sections = operation_properties(document, handle, &operation); + let operands = composite_operands(document, handle); + let selected = operation + .base() + .and_then(|base| operands.iter().find(|(id, _)| *id == base.node_id())); + if let (Some((_, selected)), Some(first)) = (selected, sections.first_mut()) { + first.props.insert( + 0, + Property { + label: t!("Operand").into_owned(), + field: PROP_OPERAND, + value: PropValue::Choice { + selected: selected.clone(), + options: operands.iter().map(|(_, label)| label.clone()).collect(), + }, + }, + ); + } + sections +} + +fn operation_properties( + document: &codec::CadDocument, + handle: codec::Handle, + operation: &SolidHistoryOperation, +) -> Vec { + match operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } @@ -1706,7 +1841,7 @@ 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 + matches!(field, "solid_history_type" | PROP_OPERAND | PROP_BANK | PROP_SWEEP_LENGTH | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z | PROP_SURFACE_TYPE | PROP_SURFACE_WIREFRAME_TYPE @@ -3372,18 +3507,14 @@ pub fn chamfer_distance_grips( handle: codec::Handle, value: &SolidHistoryChamfer, ) -> Vec { - let Some(operations) = document.solid_history_operations(handle) else { - return Vec::new(); - }; - let Some(chamfer_index) = operations - .iter() - .rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_))) + // The chamfer's edge ordinals name edges of the solid it was cut from. + let Some(source) = document + .solid_history_tree(handle) + .and_then(|tree| tree.find(value.base.node_id())?.operands.first().cloned()) + .and_then(|operand| kernel::acis::rebuild_history_tree(&operand).ok()) else { return Vec::new(); }; - let Ok(source) = kernel::acis::rebuild_history(&operations[..chamfer_index]) else { - return Vec::new(); - }; let edges = source.edge_keys().collect::>(); let faces = source.face_keys().collect::>(); let Some(edge) = value @@ -3469,10 +3600,11 @@ pub fn primitive_grips( document: &codec::CadDocument, handle: codec::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { + let Some(mut operation) = edit_operand(document, handle) + .or_else(|| document.solid_history_operation(handle).cloned()) + else { return Vec::new(); }; - let mut operation = operation.clone(); corner_frame(&mut operation); let operation = &operation; if let SolidHistoryOperation::Fillet(value) = operation { diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 97dbb38af..b30179653 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -913,6 +913,87 @@ impl Scene { true } + /// Make `target`'s history the boolean steps that built it: starting from + /// `first`'s history, each of `steps` joins a solid's history into the + /// result so far. When `first` is `target` its own history is kept; a + /// solid with no history starts from its body in `bodies`. + /// + /// False, with `target` left without a history, when one of the solids + /// cannot carry one. + pub fn join_boolean_histories( + &mut self, + target: Handle, + first: Handle, + steps: &[(Handle, u8)], + bodies: &std::collections::HashMap, + ) -> bool { + let solids = std::iter::once(target) + .chain(std::iter::once(first)) + .chain(steps.iter().map(|(handle, _)| *handle)) + .collect::>(); + let solid = |handle: &Handle| { + matches!(self.document.get_entity(*handle), Some(EntityType::Solid3D(_))) + }; + if !solids.iter().all(solid) { + return false; + } + for handle in solids.iter().skip(1) { + if self.document.solid_history_graph(*handle).is_some() { + continue; + } + let Some(body) = bodies.get(handle) else { + return false; + }; + if !self.create_solid_history( + *handle, + crate::scene::model::solid_history::brep_op(body), + ) { + return false; + } + } + if first != target { + self.delete_solid_history(target); + if self.is_recording_undo() { + let before = self.document.get_entity_arc(target); + self.record_undo_before(target, before); + } + if !self.copy_solid_history(first, target) { + return false; + } + } + for &(tool, operation) in steps { + if !self.merge_solid_history_boolean(target, tool, operation) { + self.delete_solid_history(target); + return false; + } + } + true + } + + fn merge_solid_history_boolean(&mut self, target: Handle, tool: Handle, operation: u8) -> bool { + let previous: Vec = [target, tool] + .iter() + .filter_map(|handle| self.document.solid_history_graph(*handle)) + .flat_map(|graph| graph.nodes.into_iter().chain(graph.evaluation_graph)) + .collect(); + self.record_solid_history_before(target); + self.record_solid_history_before(tool); + if self.is_recording_undo() { + // The tool loses its history link; undo must give it back. + let before = self.document.get_entity_arc(tool); + self.record_undo_before(tool, before); + } + let Some(graph) = self.document.merge_solid_history_boolean(target, tool, operation) else { + return false; + }; + for node in graph.nodes.into_iter().chain(graph.evaluation_graph) { + if !previous.contains(&node) { + self.record_undo_object_before(node, None); + } + } + true + } + pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document @@ -971,26 +1052,9 @@ impl Scene { kernel::acis::rebuild_body(operation).ok() } (EntityType::Solid3D(_), _) => { - let mut operations = self.document.solid_history_operations(handle)?; - let replacement_id = operation.base().map(|base| { - if base.eval.node_id > 0 { - base.eval.node_id - } else { - base.step_id - } - })?; - let target = operations.iter_mut().find(|candidate| { - candidate.base().is_some_and(|base| { - let node_id = if base.eval.node_id > 0 { - base.eval.node_id - } else { - base.step_id - }; - node_id == replacement_id - }) - })?; - *target = operation.clone(); - kernel::acis::rebuild_history(&operations).ok() + let mut tree = self.document.solid_history_tree(handle)?; + tree.find_mut(operation.base()?.node_id())?.operation = operation.clone(); + kernel::acis::rebuild_history_tree(&tree).ok() } _ => None, } @@ -1126,11 +1190,18 @@ impl Scene { if self.history_surface_data(handle, &operation).is_none() { return false; } - if self + // An operand inside a composite is a step below the active one. + let active = self .document - .update_solid_history(handle, operation) - .is_none() - { + .solid_history_operation(handle) + .and_then(|active| active.base()) + .map(|base| base.node_id()); + let updated = if operation.base().map(|base| base.node_id()) == active { + self.document.update_solid_history(handle, operation).is_some() + } else { + self.document.update_solid_history_step(handle, operation).is_some() + }; + if !updated { return false; } let (isoline_counts, planar_isolines) = match self.document.get_entity(handle) { @@ -1270,7 +1341,10 @@ impl Scene { grip_id: usize, apply: GripApply, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::edit_operand(&self.document, handle) + .or_else(|| self.document.solid_history_operation(handle).cloned()) + else { return false; }; if grip_id == crate::scene::model::solid_history::GRIP_FILLET_RADIUS { @@ -1345,6 +1419,11 @@ impl Scene { { return false; } + let Some(operation) = + crate::scene::model::solid_history::operand_to_history(&self.document, handle, operation) + else { + return false; + }; self.preview_solid_history(handle, operation) } @@ -1367,6 +1446,11 @@ impl Scene { ) { return false; } + let Some(operation) = + crate::scene::model::solid_history::operand_to_history(&self.document, handle, operation) + else { + return false; + }; self.rebuild_solid_history(handle, operation) }