Merge remote-tracking branch 'origin/main' into codex/shell-parity

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ocs_doc_api/Cargo.toml
#	src/modules/model/mod.rs
This commit is contained in:
ramox81 committed 2026-09-08 19:49:35 +03:00
commit 7913f8e2da
68 files changed
+5496 -548

No files matched your search

+523 -105
View File
@@ -7,7 +7,7 @@ use acadrust::{
objects::SolidHistoryOperation,
EntityType, Handle,
};
use cadkernel::brep::Body;
use cadkernel::brep::{Body, EdgeKey, FaceKey};
use iced::Task;
use std::collections::{HashMap, HashSet};
@@ -76,6 +76,35 @@ struct PreparedSubtract {
result: Option<(Handle, EntityType, Body, PreparedSolidDisplay)>,
}
struct PreparedSlice {
retained: Handle,
entity: EntityType,
body: Body,
display: PreparedSolidDisplay,
extra: Option<(EntityType, Body)>,
}
enum ModelSliceTool {
Plane(cadkernel::space::Plane),
Surface(Body),
}
impl ModelSliceTool {
fn side(&self, point: [f64; 3]) -> Option<f64> {
match self {
Self::Plane(plane) => plane.distance_to(point),
Self::Surface(surface) => cadkernel::brep::surface_side(surface, point),
}
}
fn split(&self, body: &Body) -> Result<Option<cadkernel::brep::PlaneSlice>, cadkernel::brep::Snag> {
match self {
Self::Plane(plane) => cadkernel::brep::slice_by_plane(body, *plane),
Self::Surface(surface) => cadkernel::brep::slice_by_surface(body, surface),
}
}
}
struct IntersectGroup {
kind: UnionEntityKind,
plane: Option<cadkernel::space::Plane>,
@@ -244,6 +273,29 @@ fn entity_with_boolean_body(mut source: EntityType, body: &Body) -> Option<Entit
Some(source)
}
fn entity_with_history_body(mut source: EntityType, body: &Body) -> Option<EntityType> {
let document = crate::scene::convert::acis_export::solid_to_sat(body)?;
let wires = solid_model::edge_wires(body);
match &mut source {
EntityType::Solid3D(entity) => {
entity.wires = wires;
entity.silhouettes.clear();
entity.set_sat_document(&document);
}
EntityType::Surface(entity) => {
entity.wires = wires;
entity.silhouettes.clear();
if entity.kind != SurfaceKind::Plane {
entity.kind = SurfaceKind::Generic;
entity.surface_data = SurfaceData::Generic;
}
entity.acis_data = AcisData::from_sat(&document.to_sat_string());
}
_ => return None,
}
Some(source)
}
enum IntersectBodyOutcome {
Area(Body),
Touching,
@@ -551,44 +603,169 @@ impl super::OpenCADStudio {
groups
}
fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool {
fn replace_solid_body_with_fillet(
&mut self,
handle: Handle,
source: &Body,
result: Body,
edges: &[EdgeKey],
radius: f64,
) -> bool {
let i = self.active_tab;
let Some(document) =
crate::scene::convert::acis_export::solid_to_sat(&result)
else {
self.command_line
.push_error(crate::t!("The result could not be encoded as ACIS.").as_ref());
return false;
};
let Some(EntityType::Solid3D(mut entity)) =
self.tabs[i].scene.document.get_entity(handle).cloned()
else {
return false;
};
entity.wires = solid_model::edge_wires(&result);
entity.silhouettes.clear();
entity.history_handle = None;
entity.set_sat_document(&document);
let Some(display) = self.tabs[i].scene.prepare_solid_model_display(handle, &result)
let Some(display) = self.tabs[i]
.scene
.prepare_solid_model_display(handle, &result)
.filter(|display| display.0.complete)
else {
self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref());
self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref());
return false;
};
self.push_undo_snapshot(i, label);
self.tabs[i].scene.delete_solid_history(handle);
if !self.tabs[i]
.scene
.update_entity(EntityType::Solid3D(entity))
let ordered = source.edge_keys().collect::<Vec<_>>();
let Some(history_edges) = edges
.iter()
.map(|edge| {
ordered
.iter()
.position(|candidate| candidate == edge)
.and_then(|ordinal| i32::try_from(ordinal).ok())
})
.collect::<Option<Vec<_>>>()
else {
self.command_line
.push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref());
return false;
};
self.push_undo_snapshot(i, "FILLETEDGE");
if self.tabs[i].scene.document.solid_history_graph(handle).is_none()
&& !self.tabs[i]
.scene
.create_solid_history(handle, solid_history::brep_op(source))
{
self.command_line
.push_error(crate::t!("The solid could not be updated.").as_ref());
.push_error(crate::t!("The edge body history could not be created.").as_ref());
return false;
}
let history = solid_history::brep_op(&result);
self.tabs[i].scene.create_solid_history(handle, history);
self.tabs[i].scene.register_prepared_solid_model(handle, result, display);
if !self.tabs[i].scene.append_solid_history(
handle,
solid_history::fillet_op(history_edges, radius),
) {
self.command_line
.push_error(crate::t!("The fillet history could not be recorded.").as_ref());
return false;
}
let _ = self.tabs[i].scene.apply_solid_history_choice(
handle,
solid_history::PROP_HISTORY,
"Record",
);
let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
return false;
};
let Some(entity) = entity_with_history_body(entity, &result) else {
return false;
};
if !self.tabs[i].scene.update_entity(entity) {
self.command_line
.push_error(crate::t!("The edge body could not be updated.").as_ref());
return false;
}
self.tabs[i]
.scene
.register_prepared_solid_model(handle, result, display);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].dirty = true;
self.refresh_properties();
true
}
fn replace_edge_body_with_chamfer(
&mut self,
handle: Handle,
source: &Body,
result: Body,
edges: &[EdgeKey],
base_face: FaceKey,
base_distance: f64,
other_distance: f64,
) -> bool {
let i = self.active_tab;
let Some(display) = self.tabs[i]
.scene
.prepare_solid_model_display(handle, &result)
.filter(|display| display.0.complete)
else {
self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref());
return false;
};
let ordered_edges = source.edge_keys().collect::<Vec<_>>();
let Some(history_edges) = edges
.iter()
.map(|edge| {
ordered_edges
.iter()
.position(|candidate| candidate == edge)
.and_then(|ordinal| i32::try_from(ordinal).ok())
})
.collect::<Option<Vec<_>>>()
else {
self.command_line
.push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref());
return false;
};
let Some(history_face) = source
.face_keys()
.position(|candidate| candidate == base_face)
.and_then(|ordinal| i32::try_from(ordinal).ok())
else {
self.command_line
.push_error(crate::t!("The selected base face no longer belongs to the body.").as_ref());
return false;
};
self.push_undo_snapshot(i, "CHAMFEREDGE");
if self.tabs[i].scene.document.solid_history_graph(handle).is_none()
&& !self.tabs[i]
.scene
.create_solid_history(handle, solid_history::brep_op(source))
{
self.command_line
.push_error(crate::t!("The edge body history could not be created.").as_ref());
return false;
}
if !self.tabs[i].scene.append_solid_history(
handle,
solid_history::chamfer_op(
history_edges,
history_face,
base_distance,
other_distance,
),
) {
self.command_line
.push_error(crate::t!("The chamfer history could not be recorded.").as_ref());
return false;
}
let _ = self.tabs[i].scene.apply_solid_history_choice(
handle,
solid_history::PROP_HISTORY,
"Record",
);
let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
return false;
};
let Some(entity) = entity_with_history_body(entity, &result) else {
return false;
};
if !self.tabs[i].scene.update_entity(entity) {
self.command_line
.push_error(crate::t!("The edge body could not be updated.").as_ref());
return false;
}
self.tabs[i]
.scene
.register_prepared_solid_model(handle, result, display);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].dirty = true;
@@ -599,20 +776,21 @@ impl super::OpenCADStudio {
pub(super) fn solid_edge_blend(
&mut self,
handle: Handle,
pick: glam::DVec3,
edges: &[EdgeKey],
base_face: Option<FaceKey>,
value: f64,
other_value: f64,
fillet: bool,
) -> Task<Message> {
let i = self.active_tab;
if self.reject_locked_edit(i, handle) {
return Task::none();
}
if !matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(EntityType::Solid3D(_))
) {
if !matches!(self.tabs[i].scene.document.get_entity(handle), Some(
EntityType::Solid3D(_) | EntityType::Surface(_)
)) {
self.command_line
.push_error(crate::t!("Select a 3D solid edge.").as_ref());
.push_error(crate::t!("Select a 3D solid or surface edge.").as_ref());
return Task::none();
}
self.tabs[i].scene.restore_solid_models(&[handle]);
@@ -621,15 +799,35 @@ impl super::OpenCADStudio {
.push_error(crate::t!("The solid geometry could not be restored.").as_ref());
return Task::none();
};
let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else {
self.command_line
.push_error(crate::t!("Select a solid edge.").as_ref());
return Task::none();
};
let result = if fillet {
cadkernel::brep::fillet(&body, edge, value)
match cadkernel::brep::fillet_edges(&body, edges, value) {
Ok(result) => Some(result),
Err(error) => {
self.command_line
.push_error(&format!("FILLETEDGE: {error}"));
return Task::none();
}
}
} else {
cadkernel::brep::chamfer(&body, edge, value)
let Some(base_face) = base_face else {
self.command_line
.push_error(crate::t!("CHAMFEREDGE: select a base face edge.").as_ref());
return Task::none();
};
match cadkernel::brep::chamfer_edges(
&body,
edges,
base_face,
value,
other_value,
) {
Ok(result) => Some(result),
Err(error) => {
self.command_line
.push_error(&format!("CHAMFEREDGE: {error}"));
return Task::none();
}
}
};
let Some(result) = result else {
self.command_line.push_error(
@@ -638,8 +836,25 @@ impl super::OpenCADStudio {
);
return Task::none();
};
let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" };
if self.replace_solid_body(handle, result, label) {
let label = if fillet { "FILLETEDGE" } else { "CHAMFEREDGE" };
let updated = if fillet {
self.replace_solid_body_with_fillet(handle, &body, result, edges, value)
} else {
self.replace_edge_body_with_chamfer(
handle,
&body,
result,
edges,
base_face.expect("checked chamfer face"),
value,
other_value,
)
};
if updated {
if !fillet {
self.tabs[i].scene.document.header.chamfer_distance_a = value;
self.tabs[i].scene.document.header.chamfer_distance_b = other_value;
}
self.command_line
.push_output(crate::tf!("{label}: solid updated.").as_ref());
}
@@ -740,10 +955,42 @@ impl super::OpenCADStudio {
}
};
if self.replace_solid_body(handle, result, "SHELL") {
let Some(source) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
return Task::none();
};
let Some(entity) = entity_with_boolean_body(source, &result) else {
self.command_line
.push_output(crate::t!("SHELL: solid updated.").as_ref());
.push_error(crate::t!("The result could not be encoded as ACIS.").as_ref());
return Task::none();
};
let Some(display) = self.tabs[i]
.scene
.prepare_solid_model_display(handle, &result)
.filter(|display| display.0.complete)
else {
self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref());
return Task::none();
};
self.push_undo_snapshot(i, "SHELL");
self.tabs[i].scene.delete_solid_history(handle);
if !self.tabs[i].scene.update_entity(entity) {
self.command_line
.push_error(crate::t!("The solid could not be updated.").as_ref());
return Task::none();
}
self.tabs[i]
.scene
.create_solid_history(handle, solid_history::brep_op(&result));
self.tabs[i]
.scene
.register_prepared_solid_model(handle, result, display);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line
.push_output(crate::t!("SHELL: solid updated.").as_ref());
Task::none()
}
@@ -1374,77 +1621,248 @@ impl super::OpenCADStudio {
Task::none()
}
/// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 =
/// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The
/// kept half is the intersection of the solid with a half-space box, reusing
/// the same boolean path as the modelling tools.
pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task<Message> {
/// Split every selected solid or surface by an arbitrary plane. All
/// geometry, persistence records, and displays are prepared before the
/// first source entity is changed.
pub(super) fn solid_slice(
&mut self,
requested: &[Handle],
plane: cadkernel::space::Plane,
keep_point: Option<glam::DVec3>,
) -> Task<Message> {
self.solid_slice_with_tool(requested, ModelSliceTool::Plane(plane), keep_point)
}
pub(super) fn solid_slice_surface(
&mut self,
requested: &[Handle],
cutter: Body,
keep_point: Option<glam::DVec3>,
) -> Task<Message> {
self.solid_slice_with_tool(requested, ModelSliceTool::Surface(cutter), keep_point)
}
fn solid_slice_with_tool(
&mut self,
requested: &[Handle],
tool: ModelSliceTool,
keep_point: Option<glam::DVec3>,
) -> Task<Message> {
let i = self.active_tab;
let handles = self.selected_solid_handles();
if handles.len() != 1 {
self.command_line
.push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref());
let mut handles = requested
.iter()
.copied()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(EntityType::Solid3D(_) | EntityType::Surface(_))
)
})
.collect::<Vec<_>>();
handles.sort_unstable_by_key(|handle| handle.value());
handles.dedup();
self.tabs[i].scene.restore_solid_models(&handles);
handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle));
if handles.is_empty() {
self.command_line.push_error(
crate::t!("SLICE: select at least one solid or surface.").as_ref(),
);
return Task::none();
}
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
// Bounding box from the solid's edge wires.
let Some((min, max)) = solid_model::extent(&solid) else {
self.command_line
.push_error(crate::t!("SLICE: could not determine the solid's extent.").as_ref());
return Task::none();
};
// Generous margin so the box fully spans the solid in the free axes.
let m = [
(max[0] - min[0]).max(1.0),
(max[1] - min[1]).max(1.0),
(max[2] - min[2]).max(1.0),
];
let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]];
let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]];
if keep_low {
hi[axis] = value;
let keep_positive = if let Some(point) = keep_point {
let tolerance = cadkernel::brep::operation_tolerance(
&handles
.iter()
.map(|handle| &self.tabs[i].scene.solid_models[handle])
.collect::<Vec<_>>(),
);
let Some(distance) = tool.side(point.to_array()) else {
self.command_line
.push_error(crate::t!("SLICE: the cutting plane is invalid.").as_ref());
return Task::none();
};
if distance.abs() <= tolerance {
self.command_line.push_error(
crate::t!("SLICE: choose a point away from the cutting plane.").as_ref(),
);
return Task::none();
}
Some(distance > 0.0)
} else {
lo[axis] = value;
}
if hi[axis] <= lo[axis] {
self.command_line
.push_error(crate::t!("SLICE: the plane does not cross the solid on the kept side.").as_ref());
return Task::none();
}
let center = [
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
];
let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
let Some(result) = halfspace
.as_ref()
.and_then(|half| solid_model::boolean(Bool::Intersect, &solid, half))
else {
self.command_line
.push_error(crate::t!("SLICE failed — the plane may not cross the solid.").as_ref());
return Task::none();
None
};
let mut prepared = Vec::with_capacity(handles.len());
for handle in &handles {
let body = &self.tabs[i].scene.solid_models[handle];
let result = match tool.split(body) {
Ok(Some(result)) => result,
Ok(None) => {
self.command_line.push_error(
crate::t!("SLICE: the cutting plane does not cross every selected object; no object was changed.")
.as_ref(),
);
return Task::none();
}
Err(cadkernel::brep::Snag::NoClosedForm) => {
self.command_line.push_error(
crate::t!("SLICE: the selected geometry has an unsupported surface intersection; no object was changed.")
.as_ref(),
);
return Task::none();
}
Err(cadkernel::brep::Snag::Coincident) => {
self.command_line.push_error(
crate::t!("SLICE: the cutting plane is coincident with source geometry; no object was changed.")
.as_ref(),
);
return Task::none();
}
Err(cadkernel::brep::Snag::CutRefused) => {
self.command_line.push_error(
crate::t!("SLICE: the selected topology could not be split safely; no object was changed.")
.as_ref(),
);
return Task::none();
}
};
let (body, extra_body) = match keep_positive {
Some(true) => (result.positive, None),
Some(false) => (result.negative, None),
None => (result.negative, Some(result.positive)),
};
let Some(source) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
return Task::none();
};
let Some(entity) = entity_with_boolean_body(source.clone(), &body) else {
self.command_line.push_error(
crate::t!("SLICE: a result could not be encoded; no object was changed.")
.as_ref(),
);
return Task::none();
};
let Some(display) = self.tabs[i]
.scene
.prepare_solid_model_display(*handle, &body)
.filter(|display| display.0.complete)
else {
self.command_line.push_error(
crate::t!("SLICE: a result could not be displayed completely; no object was changed.")
.as_ref(),
);
return Task::none();
};
let extra = match extra_body {
Some(extra_body) => {
let mut extra_entity = match entity_with_boolean_body(source, &extra_body) {
Some(entity) => entity,
None => {
self.command_line.push_error(
crate::t!("SLICE: a result could not be encoded; no object was changed.")
.as_ref(),
);
return Task::none();
}
};
extra_entity.common_mut().handle = Handle::NULL;
Some((extra_entity, extra_body))
}
None => None,
};
prepared.push(PreparedSlice {
retained: *handle,
entity,
body,
display,
extra,
});
}
self.push_undo_snapshot(i, "SLICE");
self.tabs[i].scene.erase_entities(&handles);
let mut s3d = Solid3D::new();
s3d.wires = solid_model::edge_wires(&result);
let history = solid_history::brep_op(&result);
let handle = self.add_solid_model(EntityType::Solid3D(s3d), result, history);
let mut created = Vec::new();
for item in &prepared {
let Some((entity, body)) = &item.extra else {
continue;
};
let handle = match entity {
EntityType::Solid3D(_) => self.add_solid_model_preserving_style(
entity.clone(),
body.clone(),
solid_history::brep_op(body),
),
EntityType::Surface(_) => {
self.add_surface_model_preserving_style(entity.clone(), body.clone())
}
_ => Handle::NULL,
};
if handle.is_null() {
self.tabs[i].scene.rollback_new_entities(&created);
self.discard_last_undo_entry(i);
self.command_line.push_error(
crate::t!("SLICE: a result could not be committed; all source objects were retained.")
.as_ref(),
);
return Task::none();
}
created.push(handle);
}
let record_history = self.tabs[i].scene.document.header.record_solid_history;
let mut retained = Vec::with_capacity(prepared.len());
for item in prepared {
self.tabs[i].scene.delete_solid_history(item.retained);
if !self.tabs[i].scene.update_entity(item.entity) {
self.command_line.push_error(
crate::t!("SLICE: a retained result could not be updated.").as_ref(),
);
return Task::none();
}
if record_history
&& matches!(
self.tabs[i].scene.document.get_entity(item.retained),
Some(EntityType::Solid3D(_))
)
{
self.tabs[i]
.scene
.create_solid_history(item.retained, solid_history::brep_op(&item.body));
}
self.tabs[i].scene.register_prepared_solid_model(
item.retained,
item.body,
item.display,
);
retained.push(item.retained);
}
retained.extend(created);
self.tabs[i].scene.deselect_all();
if !handle.is_null() {
self.tabs[i].scene.select_entity(handle, false);
for handle in &retained {
self.tabs[i].scene.select_entity(*handle, false);
}
self.tabs[i].dirty = true;
self.refresh_properties();
let ax = ["X", "Y", "Z"][axis];
self.command_line.push_output(crate::tf!(
"SLICE: cut at {ax}={value}, kept the {} half.",
if keep_low { "lower" } else { "upper" }
).as_ref());
self.command_line.push_output(
crate::tf!(
"SLICE: created %{count} result object(s).",
count = retained.len()
)
.as_ref(),
);
Task::none()
}
pub(super) fn slice_selected(
&mut self,
plane: cadkernel::space::Plane,
keep_point: Option<glam::DVec3>,
) -> Task<Message> {
let handles = self.tabs[self.active_tab].scene.selected_handles_in_order();
self.solid_slice(&handles, plane, keep_point)
}
/// INTERFERE — create a solid from the overlap of the two selected solids,
/// leaving the originals in place (a non-destructive boolean intersect).
pub(super) fn solid_interfere(&mut self) -> Task<Message> {