feat(model): add exact solid modelling tools

This commit is contained in:
Hakan Seven committed 2026-08-23 22:53:31 +03:00
1 parent fb2ea1e831
commit 06cd0ec6a9
51 files changed
+1487 -574

No files matched your search

+204 -79
View File
@@ -1,15 +1,9 @@
// 3D solid modelling support on the App: committing Model-tab primitives,
// and the Design-group boolean operations over the scene's cached
// B-reps.
//
// The bodies come from the geometry kernel, so every operation here is on
// analytic surfaces rather than on facets — a turned cylinder is still a
// cylinder afterwards, and saves as one.
// Kernel B-rep solid modelling and exact ACIS persistence.
use acadrust::entities::Solid3D;
use acadrust::objects::SolidHistoryOperation;
use acadrust::{
entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle,
};
use cadkernel::brep::Body;
use acadrust::{EntityType, Handle};
use iced::Task;
use super::Message;
@@ -18,16 +12,25 @@ use crate::scene::model::solid_history;
use crate::scene::model::solid_model::{self, Bool};
impl super::OpenCADStudio {
/// Commit a Model-tab solid: add its acadrust entity to the document, then
/// register the B-rep (caches it for booleans + tessellates it into the
/// shaded mesh pipeline). Returns the new entity handle.
/// Add a solid and register its persistent B-rep.
pub(super) fn add_solid_model(
&mut self,
entity: EntityType,
mut entity: EntityType,
solid: Body,
history: SolidHistoryOperation,
) -> Handle {
let i = self.active_tab;
let EntityType::Solid3D(inner) = &mut entity else {
return Handle::NULL;
};
inner.wires = solid_model::edge_wires(&solid);
let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid)
else {
self.command_line
.push_error(crate::t!("The solid could not be encoded as ACIS.").as_ref());
return Handle::NULL;
};
inner.set_sat_document(&document);
let Some(handle) = self.commit_entity_handle(entity) else {
return Handle::NULL;
};
@@ -55,35 +58,183 @@ impl super::OpenCADStudio {
handles
}
/// Run a boolean (`union` / `subtract` / `intersect`) on exactly two
/// selected solids whose B-reps can be restored by the kernel.
fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> 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);
self.push_undo_snapshot(i, label);
self.tabs[i].scene.delete_solid_history(handle);
if !self.tabs[i]
.scene
.update_entity(EntityType::Solid3D(entity))
{
self.command_line
.push_error(crate::t!("The solid could not be updated.").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_solid_model(handle, result);
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].dirty = true;
self.refresh_properties();
true
}
pub(super) fn solid_edge_blend(
&mut self,
handle: Handle,
pick: glam::DVec3,
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(_))
) {
self.command_line
.push_error(crate::t!("Select a 3D solid edge.").as_ref());
return Task::none();
}
self.tabs[i].scene.restore_solid_models(&[handle]);
let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else {
self.command_line
.push_error(crate::t!("The solid geometry could not be restored.").as_ref());
return Task::none();
};
let Some(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)
} else {
cadkernel::brep::chamfer(&body, edge, value)
};
let Some(result) = result else {
self.command_line.push_error(
crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.")
.as_ref(),
);
return Task::none();
};
let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" };
if self.replace_solid_body(handle, result, label) {
self.command_line
.push_output(crate::tf!("{label}: solid updated.").as_ref());
}
Task::none()
}
pub(super) fn solid_face_presspull(
&mut self,
handle: Handle,
pick: glam::DVec3,
distance: f64,
drag: Option<glam::DVec3>,
) -> Task<Message> {
let i = self.active_tab;
if self.reject_locked_edit(i, handle) {
return Task::none();
}
self.tabs[i].scene.restore_solid_models(&[handle]);
let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else {
self.command_line
.push_error(crate::t!("The solid geometry could not be restored.").as_ref());
return Task::none();
};
let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else {
self.command_line
.push_error(crate::t!("Select a planar solid face.").as_ref());
return Task::none();
};
let distance = match drag {
Some(point) => {
let Some(normal) = solid_model::planar_face_normal(&body, face) else {
self.command_line
.push_error(crate::t!("Select a planar solid face.").as_ref());
return Task::none();
};
(point - pick).dot(glam::DVec3::from_array(normal))
}
None => distance,
};
if !distance.is_finite() || distance.abs() <= 1e-6 {
self.command_line.push_error(
crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(),
);
return Task::none();
}
let Some(result) = cadkernel::brep::presspull(&body, face, distance) else {
self.command_line.push_error(
crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.")
.as_ref(),
);
return Task::none();
};
if self.replace_solid_body(handle, result, "PRESSPULL") {
self.command_line
.push_output(crate::t!("PRESSPULL: solid updated.").as_ref());
}
Task::none()
}
/// Run a boolean over the selected solids in selection order.
pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task<Message> {
let i = self.active_tab;
let handles = self.selected_solid_handles();
if handles.len() != 2 {
if handles.len() < 2 {
self.command_line
.push_error(crate::t!("Boolean: select exactly two solids created this session.").as_ref());
.push_error(crate::t!("Boolean: select at least two solids.").as_ref());
return Task::none();
}
let a = self.tabs[i].scene.solid_models[&handles[0]].clone();
let b = self.tabs[i].scene.solid_models[&handles[1]].clone();
let kind = match op {
BoolOp::Union => Bool::Union,
BoolOp::Subtract => Bool::Subtract,
BoolOp::Intersect => Bool::Intersect,
};
let Some(result) = solid_model::boolean(kind, &a, &b) else {
let mut operands = handles
.iter()
.filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned());
let mut result = operands.next().expect("at least two restored solids");
for operand in operands {
let Some(combined) = solid_model::boolean(kind, &result, &operand) else {
self.command_line.push_error(
crate::t!("Boolean failed while combining the selected solids.").as_ref(),
);
return Task::none();
};
result = combined;
}
if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() {
self.command_line
.push_error(crate::t!("Boolean failed — the solids may not overlap.").as_ref());
.push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref());
return Task::none();
};
}
self.push_undo_snapshot(i, "BOOLEAN");
// Remove the two operands (entity + mesh + cached B-rep).
self.tabs[i].scene.erase_entities(&handles);
// The result is freshly combined geometry with no ACIS parametrisation,
// so it lives as a Solid3D whose render/boolean data is the injected
// mesh + cached B-rep; its edge wires make it pickable.
let mut s3d = Solid3D::new();
s3d.wires = solid_model::edge_wires(&result);
let history = solid_history::brep_op(&result);
@@ -100,7 +251,7 @@ impl super::OpenCADStudio {
/// 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 the Design-group tools use.
/// the same boolean path as the modelling tools.
pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task<Message> {
let i = self.active_tab;
let handles = self.selected_solid_handles();
@@ -243,63 +394,34 @@ impl super::OpenCADStudio {
Task::none()
}
/// POLYSOLID — build a wall-like solid from a selected polyline: one box per
/// segment (oriented along it, `width` wide, `height` tall) unioned together,
/// reusing the box primitive + the boolean union path.
/// POLYSOLID — build a wall solid around an exact polyline offset.
pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task<Message> {
let i = self.active_tab;
let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i]
let found: Option<(Handle, EntityType)> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h))
.find_map(|(h, e)| match e {
EntityType::LwPolyline(_) => crate::entities::curve::entity_curve(e)
.map(|curve| (*h, crate::entities::curve::curve_points(&curve))),
EntityType::LwPolyline(_) => Some((*h, (*e).clone())),
_ => None,
});
let Some((handle, pts)) = found else {
let Some((handle, entity)) = found else {
self.command_line
.push_error(crate::t!("POLYSOLID: select a polyline first.").as_ref());
return Task::none();
};
let segs: Vec<([f64; 3], [f64; 3])> = pts.windows(2).map(|w| (w[0], w[1])).collect();
let mut acc: Option<Body> = None;
let mut used = 0usize;
for (a, b) in &segs {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
continue;
}
let angle = dy.atan2(dx);
// Local box: X 0..len, Y -w/2..w/2, Z 0..h, then turned to lie
// along the segment and moved onto its start.
let (sin, cos) = angle.sin_cos();
let Some(bx) = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height)
.and_then(|bx| {
solid_model::placed(
&bx,
[cos, sin, 0.0],
[-sin, cos, 0.0],
[0.0, 0.0, 1.0],
[a[0], a[1], a[2]],
)
})
else {
continue;
};
acc = Some(match acc {
None => bx,
Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev),
});
used += 1;
}
let Some(result) = acc else {
let Some(result) = crate::scene::model::sweep_model::polysolid(&entity, width, height)
else {
self.command_line
.push_error(crate::t!("POLYSOLID: the polyline has no usable segments.").as_ref());
.push_error(crate::t!("POLYSOLID: the kernel could not offset the polyline.").as_ref());
return Task::none();
};
if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() {
self.command_line
.push_error(crate::t!("POLYSOLID: the result could not be encoded as ACIS.").as_ref());
return Task::none();
}
self.push_undo_snapshot(i, "POLYSOLID");
self.tabs[i].scene.erase_entities(&[handle]);
let mut s3d = Solid3D::new();
@@ -310,11 +432,14 @@ impl super::OpenCADStudio {
if !h.is_null() {
self.tabs[i].scene.select_entity(h, false);
}
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line.push_output(crate::tf!(
"POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})."
).as_ref());
if !h.is_null() {
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line.push_output(
crate::tf!("POLYSOLID: created a wall solid (width {width}, height {height}).")
.as_ref(),
);
}
Task::none()
}
@@ -507,12 +632,12 @@ impl super::OpenCADStudio {
self.tabs[i].scene.deselect_all();
if !handle.is_null() {
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line.push_output(crate::tf!(
"PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})."
).as_ref());
}
self.tabs[i].dirty = true;
self.refresh_properties();
self.command_line.push_output(crate::tf!(
"PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})."
).as_ref());
Task::none()
}