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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-07 11:14:13 +03:00
1 parent d1f3bf3013
commit d3033caf24
28 files changed
+383 -52

No files matched your search

+14 -6
View File
@@ -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();
+145 -13
View File
@@ -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<rustc_hash::FxHashMap<codec::Handle, i32>> =
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::<Vec<_>>();
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<glam::DMat4> {
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<SolidHistoryOperation> {
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<SolidHistoryOperation> {
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<SolidHistoryOperation> {
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<PropSection> {
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<GripDef> {
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::<Vec<_>>();
let faces = source.face_keys().collect::<Vec<_>>();
let Some(edge) = value
@@ -3469,10 +3600,11 @@ pub fn primitive_grips(
document: &codec::CadDocument,
handle: codec::Handle,
) -> Vec<GripDef> {
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 {
+109 -25
View File
@@ -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<Handle, kernel::brep::Body>,
) -> bool {
let solids = std::iter::once(target)
.chain(std::iter::once(first))
.chain(steps.iter().map(|(handle, _)| *handle))
.collect::<Vec<_>>();
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<Handle> = [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)
}