From 1e118b2e905663ee32e283960ff93a89321be523 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:32:01 +0300 Subject: [PATCH] Complete exact INTERSECT behavior --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/commands/draw.rs | 52 ++-- src/app/model_ops.rs | 451 +++++++++++++++++++++++++++++- src/scene/convert/solid3d_tess.rs | 4 + src/scene/entity.rs | 3 + src/scene/model/solid_model.rs | 7 +- 8 files changed, 497 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d4975fe2..6ce25420d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,7 +882,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6a5e6fc2822f6af6b2611cd605b44437b35158f0#6a5e6fc2822f6af6b2611cd605b44437b35158f0" +source = "git+https://github.com/ramox81/cadkernel.git?rev=fb8946b7f051b67066d044fd5bb63681b64eaa35#fb8946b7f051b67066d044fd5bb63681b64eaa35" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 5ccd655d6..9778d19c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "fb8946b7f051b67066d044fd5bb63681b64eaa35", 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/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 973bd0459..4a8c1df9f 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "fb8946b7f051b67066d044fd5bb63681b64eaa35", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 5090140ef..4e7da2319 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -975,7 +975,9 @@ impl OpenCADStudio { "UNION" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { - let solid_count = { + let ready = if op == BoolOp::Intersect { + self.intersect_ready() + } else { let scene = &self.tabs[i].scene; scene .selected_handles_in_order() @@ -989,8 +991,9 @@ impl OpenCADStudio { }) .take(2) .count() + >= 2 }; - if solid_count < 2 { + if !ready { use crate::modules::draw::select::SelectObjectsCommand; let selection = SelectObjectsCommand::new(cmd); self.command_line.push_info(&selection.prompt()); @@ -1069,9 +1072,9 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { - use acadrust::entities::{Region, Wire}; + use acadrust::entities::Region; use acadrust::types::Vector3; - let mut loops: Vec> = Vec::new(); + let mut regions = Vec::new(); for (_, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, @@ -1079,32 +1082,39 @@ impl OpenCADStudio { if pl.is_closed && pl.vertices.len() >= 3 ) || matches!(e, acadrust::EntityType::Circle(_)); if supported { - let Some(curve) = crate::entities::curve::entity_curve(e) else { + let Some((plane, loops, true)) = + crate::scene::model::presspull_model::profile_geometry(e) + else { continue; }; - loops.push( - crate::entities::curve::curve_points(&curve) - .into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])) - .collect(), + let Some(body) = cadkernel::brep::planar_region(plane, &loops) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], ); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); } } - if loops.is_empty() { + if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); } else { self.push_undo_snapshot(i, "REGION"); - let count = loops.len(); - for pts in loops { - let mut w = Wire::new(); - let first = pts.first().copied().unwrap_or(Vector3::new(0.0, 0.0, 0.0)); - w.points = pts; - let mut r = Region::new(); - r.point_of_reference = first; - r.wires = vec![w]; - r.common.layer = self.tabs[i].active_layer.clone(); - self.tabs[i].scene.add_entity(acadrust::EntityType::Region(r)); + let count = regions.len(); + let mut created = Vec::with_capacity(count); + for (region, body) in regions { + let handle = self.add_region_model(region, body); + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Some(iced::Task::none()); + } + created.push(handle); } self.tabs[i].dirty = true; self.command_line diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 38c287d23..d77dbe3c0 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,16 +1,166 @@ // Kernel B-rep solid modelling and exact ACIS persistence. use acadrust::{ - entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, + entities::{AcisData, Solid3D, SurfaceData, SurfaceKind, Wire}, + objects::SolidHistoryOperation, + EntityType, Handle, }; use cadkernel::brep::Body; use iced::Task; +use std::collections::HashMap; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; +#[derive(Clone, Copy, PartialEq, Eq)] +enum IntersectEntityKind { + Solid, + Region, + Surface, +} + +impl IntersectEntityKind { + fn from_entity(entity: &EntityType) -> Option { + Some(match entity { + EntityType::Solid3D(_) => Self::Solid, + EntityType::Region(_) => Self::Region, + EntityType::Surface(_) => Self::Surface, + _ => return None, + }) + } +} + +struct IntersectGroup { + kind: IntersectEntityKind, + plane: Option, + handles: Vec, +} + +type PreparedSolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + +enum PreparedIntersectOutcome { + Replace { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + }, + Consume, +} + +struct PreparedIntersect { + handles: Vec, + outcome: PreparedIntersectOutcome, +} + +fn intersect_planar_body_plane(body: &Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let first_normal = glam::DVec3::from_array(first.normal()?); + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + faces + .all(|face| { + let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else { + return false; + }; + let Some(normal) = profile.plane.normal() else { + return false; + }; + first_normal.dot(glam::DVec3::from_array(normal)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) + }) + .then_some(first) +} + +fn intersect_coplanar_body(plane: cadkernel::space::Plane, body: &Body) -> bool { + let Some(other) = intersect_planar_body_plane(body) else { + return false; + }; + let Some(one_normal) = plane.normal().map(glam::DVec3::from_array) else { + return false; + }; + let Some(other_normal) = other.normal().map(glam::DVec3::from_array) else { + return false; + }; + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + one_normal.dot(other_normal).abs() >= 1.0 - 1e-9 + && plane + .distance_to(other.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) +} + +fn entity_with_intersection_body(mut source: EntityType, body: &Body) -> Option { + 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.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Region(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + 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, + Disjoint, +} + +fn intersect_bodies( + kind: IntersectEntityKind, + bodies: Vec, +) -> Result { + if kind == IntersectEntityKind::Solid { + let mut operands = bodies.into_iter(); + let mut result = operands + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for operand in operands { + result = solid_model::boolean_result(Bool::Intersect, &result, &operand)?; + if result.faces.is_empty() { + return Ok(IntersectBodyOutcome::Disjoint); + } + } + return Ok(IntersectBodyOutcome::Area(result)); + } + + let references = bodies.iter().collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + Ok(match cadkernel::brep::intersect_planar_regions(&bodies, tolerance)? { + cadkernel::brep::PlanarIntersection::Area(body) => IntersectBodyOutcome::Area(body), + cadkernel::brep::PlanarIntersection::Touching => IntersectBodyOutcome::Touching, + cadkernel::brep::PlanarIntersection::Disjoint => IntersectBodyOutcome::Disjoint, + }) +} + impl super::OpenCADStudio { /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( @@ -81,6 +231,29 @@ impl super::OpenCADStudio { handle } + pub(super) fn add_region_model( + &mut self, + mut region: acadrust::entities::Region, + body: Body, + ) -> Handle { + let i = self.active_tab; + region.wires = solid_model::edge_wires(&body); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { + self.command_line + .push_error(crate::t!("The region could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + region.set_sat_document(&document); + let Some(handle) = self.commit_entity_handle(EntityType::Region(region)) else { + return Handle::NULL; + }; + if !self.tabs[i].scene.register_solid_model(handle, body) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] @@ -100,6 +273,91 @@ impl super::OpenCADStudio { handles } + fn selected_intersect_handles(&self) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + scene + .document + .get_entity(*handle) + .and_then(IntersectEntityKind::from_entity) + .is_some() + }) + .collect() + } + + pub(super) fn intersect_ready(&self) -> bool { + let scene = &self.tabs[self.active_tab].scene; + let mut counts = [0usize; 3]; + for handle in scene.selected_handles_in_order() { + if scene.is_layer_locked(handle) { + continue; + } + let Some(kind) = scene + .document + .get_entity(handle) + .and_then(IntersectEntityKind::from_entity) + else { + continue; + }; + let index = match kind { + IntersectEntityKind::Solid => 0, + IntersectEntityKind::Region => 1, + IntersectEntityKind::Surface => 2, + }; + counts[index] += 1; + } + counts.into_iter().any(|count| count >= 2) + } + + fn intersect_groups( + &self, + handles: &[Handle], + bodies: &HashMap, + ) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + let mut groups = Vec::::new(); + for handle in handles { + let Some(kind) = scene + .document + .get_entity(*handle) + .and_then(IntersectEntityKind::from_entity) + else { + continue; + }; + let plane = (kind != IntersectEntityKind::Solid) + .then(|| intersect_planar_body_plane(&bodies[handle])) + .flatten(); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && if kind == IntersectEntityKind::Solid { + true + } else { + match (group.plane, plane) { + (Some(group_plane), Some(_)) => { + intersect_coplanar_body(group_plane, &bodies[handle]) + } + (None, None) => true, + _ => false, + } + } + }) { + group.handles.push(*handle); + } else { + groups.push(IntersectGroup { + kind, + plane, + handles: vec![*handle], + }); + } + } + groups.retain(|group| group.handles.len() >= 2); + groups + } + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { let i = self.active_tab; let Some(document) = @@ -195,8 +453,199 @@ impl super::OpenCADStudio { Task::none() } + /// Intersect compatible selected solids, Regions, and coplanar Surfaces. + pub(super) fn solid_intersect(&mut self) -> Task { + let i = self.active_tab; + let mut handles = self.selected_intersect_handles(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + let bodies = handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .solid_models + .get(handle) + .cloned() + .map(|body| (*handle, body)) + }) + .collect::>(); + let groups = self.intersect_groups(&handles, &bodies); + if groups.is_empty() { + self.command_line.push_error( + crate::t!("INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type.") + .as_ref(), + ); + return Task::none(); + } + + let mut prepared = Vec::new(); + let mut retained_without_change = 0usize; + for group in groups { + let operands = group + .handles + .iter() + .map(|handle| bodies[handle].clone()) + .collect::>(); + let outcome = match intersect_bodies(group.kind, operands) { + Ok(outcome) => outcome, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected objects do not have a supported coplanar surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected coincident geometry is ambiguous.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected topology could not be intersected safely.") + .as_ref(), + ); + return Task::none(); + } + }; + + match outcome { + IntersectBodyOutcome::Area(body) => { + let retained = group.handles[0]; + let Some(source) = self.tabs[i].scene.document.get_entity(retained).cloned() + else { + return Task::none(); + }; + let Some(entity) = entity_with_intersection_body(source, &body) else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be encoded as ACIS.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(retained, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be displayed completely. The original objects were retained.") + .as_ref(), + ); + return Task::none(); + }; + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + }, + }); + } + IntersectBodyOutcome::Touching + if group.kind == IntersectEntityKind::Region + || group.kind == IntersectEntityKind::Surface => + { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Disjoint + if group.kind == IntersectEntityKind::Region => + { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Touching | IntersectBodyOutcome::Disjoint => { + retained_without_change += group.handles.len(); + } + } + } + + if prepared.is_empty() { + self.command_line.push_output( + crate::tf!( + "INTERSECT: no positive-area common result; %{count} original object(s) retained.", + count = retained_without_change + ) + .as_ref(), + ); + return Task::none(); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + self.push_undo_snapshot(i, "INTERSECT"); + let mut results = Vec::new(); + let mut consumed = Vec::new(); + for group in prepared { + match group.outcome { + PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + } => { + self.tabs[i].scene.delete_solid_history(retained); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line.push_error( + crate::t!("INTERSECT: the retained object could not be updated.") + .as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(retained), + Some(EntityType::Solid3D(_)) + ) + { + let history = solid_history::brep_op(&body); + self.tabs[i].scene.create_solid_history(retained, history); + } + self.tabs[i] + .scene + .register_prepared_solid_model(retained, body, display); + results.push(retained); + consumed.extend( + group + .handles + .into_iter() + .filter(|handle| *handle != retained), + ); + } + PreparedIntersectOutcome::Consume => consumed.extend(group.handles), + } + } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &results { + self.tabs[i].scene.select_entity(*handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!( + "INTERSECT: created %{count} result object(s).", + count = results.len() + ) + .as_ref(), + ); + Task::none() + } + /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + if op == BoolOp::Intersect { + return self.solid_intersect(); + } let i = self.active_tab; let handles = self.selected_solid_handles(); if handles.len() < 2 { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index ebfa32df8..5095dd680 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -159,6 +159,10 @@ pub fn kernel_body(solid: &Solid3D) -> Option { kernel_acis_body(&solid.acis_data) } +pub fn kernel_region_body(region: &Region) -> Option { + kernel_acis_body(®ion.acis_data) +} + pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 1fe0317b4..a1dbbf3af 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -602,6 +602,9 @@ impl Scene { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } + Some(EntityType::Region(region)) => { + crate::scene::convert::solid3d_tess::kernel_region_body(region) + } Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 24969c146..d6e8c6d9a 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -365,13 +365,18 @@ pub enum Bool { /// missing, and passing that on unchanged is the point: a half-done boolean /// looks finished. pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { + boolean_result(op, a, b).ok() +} + +/// Combine two solids while retaining the kernel's exact refusal reason. +pub fn boolean_result(op: Bool, a: &Body, b: &Body) -> Result { let how = match op { Bool::Union => brep::Operation::Union, Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; let tolerance = brep::operation_tolerance(&[a, b]); - brep::combine(a.clone(), b.clone(), how, tolerance).ok() + brep::combine(a.clone(), b.clone(), how, tolerance) } // ── Tessellation ────────────────────────────────────────────────────────────