From f69f0b32bea9e89f8115e84785ff4723db3a6f57 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 9 Sep 2026 20:53:05 +0300 Subject: [PATCH] fix(sectionplane): persist and clip section modes correctly --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_doc_api/Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- src/app/history.rs | 4 + src/entities/extended.rs | 227 ++++++++++----- src/modules/model/sectionplane_cmd.rs | 63 ++++- src/scene/convert/solid3d_tess.rs | 2 +- src/scene/entity.rs | 107 ++++++- src/scene/mod.rs | 372 ++++++++++++++++++++++--- src/scene/selection.rs | 45 +++ tests/section_plane_roundtrip.rs | 110 ++++++++ 14 files changed, 831 insertions(+), 119 deletions(-) create mode 100644 tests/section_plane_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 8cd0fabfa..10aaeed9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=62628c41670a914d1598a14faafc3c6e3aa4a270#62628c41670a914d1598a14faafc3c6e3aa4a270" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f23ffc873228e5f41b876485c741e4a8ac20a67d#f23ffc873228e5f41b876485c741e4a8ac20a67d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=920ff591028ab4ba13648b920d9d333ec3b7d44b#920ff591028ab4ba13648b920d9d333ec3b7d44b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index a2c2732d5..49870b58e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "920ff591028ab4ba13648b920d9d333ec3b7d44b", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", 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 65cdd5d10..592effe98 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -14,8 +14,8 @@ bincode = "1.3" thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "920ff591028ab4ba13648b920d9d333ec3b7d44b", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 842b550f7..2433e9c6b 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 30ab7919d..dbd456778 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 0807eba93..68b944b56 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "62628c41670a914d1598a14faafc3c6e3aa4a270", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } diff --git a/src/app/history.rs b/src/app/history.rs index d424167aa..aef5c712d 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -675,6 +675,10 @@ impl OpenCADStudio { entity, EntityType::Block(_) | EntityType::BlockEnd(_) + | EntityType::Extended(acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(_), + .. + }) ) { return false; } diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 8042cb8a4..38fe46ddd 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -3,6 +3,7 @@ use acadrust::entities::{ PointCloudData, PointCloudExData, RemoteTextData, SectionObjectData, }; use acadrust::types::{Handle, Transform, Vector3}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use crate::t; use crate::command::EntityTransform; @@ -17,6 +18,69 @@ use crate::scene::model::object::{ use crate::scene::model::wire_model::SnapHint; const NAN: [f64; 3] = [f64::NAN; 3]; +const SECTION_SLICE_APP: &str = "IsSlice"; +const SECTION_THICKNESS_APP: &str = "ThicknessDepth"; + +pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { + entity + .common + .extended_data + .get_record(SECTION_SLICE_APP) + .is_some_and(|record| { + record + .values + .iter() + .rev() + .find_map(|value| match value { + XDataValue::Integer16(value) => Some(*value != 0), + XDataValue::Integer32(value) => Some(*value != 0), + _ => None, + }) + .unwrap_or(false) + }) +} + +pub(crate) fn section_slice_depth(entity: &ExtendedEntity) -> Option { + entity + .common + .extended_data + .get_record(SECTION_THICKNESS_APP)? + .values + .iter() + .find_map(|value| match value { + XDataValue::Real(value) | XDataValue::Distance(value) => Some(*value), + _ => None, + }) + .filter(|value| value.is_finite()) + .map(f64::abs) +} + +pub(crate) fn set_section_slice_metadata( + entity: &mut ExtendedEntity, + enabled: bool, + depth: f64, +) { + if !enabled { + entity.common.extended_data.remove_record(SECTION_SLICE_APP); + entity + .common + .extended_data + .remove_record(SECTION_THICKNESS_APP); + return; + } + + let mut slice = ExtendedDataRecord::new(SECTION_SLICE_APP); + slice.values = vec![XDataValue::Integer16(0), XDataValue::Integer16(1)]; + entity.common.extended_data.upsert_record(slice); + + let mut thickness = ExtendedDataRecord::new(SECTION_THICKNESS_APP); + thickness.values = vec![XDataValue::Real(if depth.is_finite() { + depth.abs() + } else { + 0.0 + })]; + entity.common.extended_data.upsert_record(thickness); +} fn vector_text(value: Vector3) -> String { format!("{:.6}, {:.6}, {:.6}", value.x, value.y, value.z) @@ -558,17 +622,17 @@ fn to_render(entity: &ExtendedEntity, document: &acadrust::CadDocument) -> Optio }) } -fn section_properties(data: &SectionObjectData) -> Vec { +fn section_properties(entity: &ExtendedEntity, data: &SectionObjectData) -> Vec { let vertices = data .vertices .iter() .map(|point| vector_text(*point)) .collect::>() .join("; "); - let kind = section_kind(data); + let kind = section_kind(entity, data); let viewing = section_viewing_direction(data); let offset = section_plane_offset(data); - let depth = section_depth(data); + let depth = section_slice_depth(entity).unwrap_or_else(|| section_depth(data)); vec![PropSection { title: t!("Section Plane").into_owned(), props: vec![ @@ -656,18 +720,14 @@ fn section_depth(data: &SectionObjectData) -> f64 { .map_or(0.0, |(front, back)| (*back - *front).length()) } -fn section_kind(data: &SectionObjectData) -> &'static str { +fn section_kind(entity: &ExtendedEntity, data: &SectionObjectData) -> &'static str { + if section_is_slice(entity) { + return "Slice"; + } match data.state { 1 => "Plane", 4 => "Volume", - 2 => { - let span = data - .vertices - .first() - .zip(data.vertices.last()) - .map_or(1.0, |(first, last)| (*last - *first).length().max(1e-9)); - if section_depth(data) < span * 0.25 { "Slice" } else { "Boundary" } - } + 2 => "Boundary", _ => "Plane", } } @@ -734,8 +794,8 @@ fn set_section_kind(data: &mut SectionObjectData, value: &str) { .map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4)); match value.trim().to_ascii_uppercase().as_str() { "SLICE" => { - data.state = 2; - set_section_depth(data, span / 60.0); + data.state = 1; + data.back_line_vertices.clear(); } "BOUNDARY" => { data.state = 2; @@ -1135,7 +1195,7 @@ fn properties(entity: &ExtendedEntity) -> Vec { title: t!("Camera").into_owned(), props: vec![ro_prop(t!("View").as_ref(), "ext_camera_view", handle_text(*view_handle))], }], - ExtendedEntityData::SectionObject(data) => section_properties(data), + ExtendedEntityData::SectionObject(data) => section_properties(entity, data), ExtendedEntityData::ArcAlignedText(data) => arc_text_properties(data), ExtendedEntityData::RemoteText(data) => remote_text_properties(data), ExtendedEntityData::GeoPositionMarker(data) => geo_marker_properties(data), @@ -1282,65 +1342,97 @@ fn set_f64(value: &str, target: &mut f64) { } } -fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { - match &mut entity.data { - ExtendedEntityData::SectionObject(data) => match field { - "ext_section_name" => data.name = value.to_string(), - "ext_section_state" | "ext_section_state2" => set_section_kind(data, value), - "ext_section_viewing" => { - if let Some(direction) = parse_vector(value) { - set_viewing_direction(data, direction); - } +fn apply_section_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { + let was_slice = section_is_slice(entity); + let stored_slice_depth = section_slice_depth(entity).unwrap_or(0.0); + let ExtendedEntityData::SectionObject(data) = &mut entity.data else { + return; + }; + let mut slice_update = None; + match field { + "ext_section_name" => data.name = value.to_string(), + "ext_section_state" | "ext_section_state2" => { + let kind = value.trim().to_ascii_uppercase(); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4)); + set_section_kind(data, &kind); + slice_update = Some((kind == "SLICE", (span / 60.0).max(1e-4))); + } + "ext_section_viewing" => { + if let Some(direction) = parse_vector(value) { + set_viewing_direction(data, direction); } - "ext_section_vertical" => { - if let Some(direction) = parse_vector(value) { - let depth = section_depth(data); - let direction = normalized(direction, data.vertical_direction); - if direction.dot(§ion_tangent(data)).abs() < 1.0 - 1e-9 { - data.vertical_direction = direction; - if depth > 0.0 { - set_section_depth(data, depth); - } - } - } - } - "ext_section_live" => data.flags ^= 1, - "ext_section_alpha" => { - if let Ok(alpha) = value.trim().parse::() { - data.indicator_alpha = alpha.clamp(0, 100); - } - } - "ext_section_elevation" | "ext_section_offset" => { - if let Some(offset) = parse_f64(value) { - move_section_to_offset(data, offset); - } - } - "ext_section_top" => { - if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { - data.top_height = height; - } - } - "ext_section_bottom" => { - if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { - data.bottom_height = height; - } - } - "ext_section_vertices" => { - if let Some(vertices) = parse_vertices(value) { - let depth = section_depth(data); - data.vertices = vertices; + } + "ext_section_vertical" => { + if let Some(direction) = parse_vector(value) { + let depth = section_depth(data); + let direction = normalized(direction, data.vertical_direction); + if direction.dot(§ion_tangent(data)).abs() < 1.0 - 1e-9 { + data.vertical_direction = direction; if depth > 0.0 { set_section_depth(data, depth); } } } - "ext_section_depth" => { - if let Some(depth) = parse_f64(value) { + } + "ext_section_live" => data.flags ^= 1, + "ext_section_alpha" => { + if let Ok(alpha) = value.trim().parse::() { + data.indicator_alpha = alpha.clamp(0, 100); + } + } + "ext_section_elevation" | "ext_section_offset" => { + if let Some(offset) = parse_f64(value) { + move_section_to_offset(data, offset); + } + } + "ext_section_top" => { + if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { + data.top_height = height; + } + } + "ext_section_bottom" => { + if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { + data.bottom_height = height; + } + } + "ext_section_vertices" => { + if let Some(vertices) = parse_vertices(value) { + let depth = section_depth(data); + data.vertices = vertices; + if depth > 0.0 { set_section_depth(data, depth); } } - _ => {} - }, + } + "ext_section_depth" => { + if let Some(depth) = parse_f64(value) { + if was_slice { + slice_update = Some((true, depth)); + } else { + set_section_depth(data, depth); + } + } + } + _ => {} + } + + if let Some((enabled, depth)) = slice_update { + set_section_slice_metadata(entity, enabled, depth); + } else if was_slice && field != "ext_section_depth" { + set_section_slice_metadata(entity, true, stored_slice_depth); + } +} + +fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { + if matches!(entity.data, ExtendedEntityData::SectionObject(_)) { + apply_section_prop(entity, field, value); + return; + } + match &mut entity.data { ExtendedEntityData::ArcAlignedText(data) => match field { "ext_arc_text" => data.text = value.to_string(), "ext_arc_font" => data.font_name = value.to_string(), @@ -1692,6 +1784,8 @@ fn transformed_planar_angle( fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) { let transform = entity_transform(requested); let scale = scalar_scale(requested); + let slice_depth = section_is_slice(entity) + .then(|| section_slice_depth(entity).unwrap_or(0.0) * scale); match &mut entity.data { ExtendedEntityData::SectionObject(data) => { for point in data @@ -1830,6 +1924,9 @@ fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) { } _ => {} } + if let Some(depth) = slice_depth { + set_section_slice_metadata(entity, true, depth); + } } impl RenderConvertible for ExtendedEntity { diff --git a/src/modules/model/sectionplane_cmd.rs b/src/modules/model/sectionplane_cmd.rs index 61b68e85f..150e8ef12 100644 --- a/src/modules/model/sectionplane_cmd.rs +++ b/src/modules/model/sectionplane_cmd.rs @@ -26,8 +26,8 @@ impl SectionKind { fn state(self) -> i32 { match self { - Self::Plane => 1, - Self::Slice | Self::Boundary => 2, + Self::Plane | Self::Slice => 1, + Self::Boundary => 2, Self::Volume => 4, } } @@ -140,12 +140,12 @@ impl SectionPlaneCommand { SectionKind::Slice => (span / 60.0).max(1e-4), SectionKind::Boundary | SectionKind::Volume => span, }; - let back_line_vertices = if depth > 0.0 { + let back_line_vertices = if matches!(self.kind, SectionKind::Boundary | SectionKind::Volume) { vertices.iter().map(|point| *point + viewing * depth).collect() } else { Vec::new() }; - EntityType::Extended(ExtendedEntity { + let mut entity = ExtendedEntity { common: EntityCommon::new(), data: ExtendedEntityData::SectionObject(SectionObjectData { state: self.kind.state(), @@ -166,7 +166,13 @@ impl SectionPlaneCommand { .collect(), settings_handle: Handle::NULL, }), - }) + }; + crate::entities::extended::set_section_slice_metadata( + &mut entity, + self.kind == SectionKind::Slice, + depth, + ); + EntityType::Extended(entity) } fn line_at(&self, point: DVec3, viewing: DVec3, vertical_hint: DVec3) -> (Vec, DVec3) { @@ -482,3 +488,50 @@ impl CadCommand for SectionPlaneCommand { inventory::submit!(crate::command::CommandRegistration { names: &["SECTIONPLANE"] }); + +#[cfg(test)] +mod tests { + use super::*; + + fn data(entity: &EntityType) -> (&ExtendedEntity, &SectionObjectData) { + let EntityType::Extended(entity) = entity else { + panic!("expected extended entity"); + }; + let ExtendedEntityData::SectionObject(data) = &entity.data else { + panic!("expected section object"); + }; + (entity, data) + } + + #[test] + fn slice_uses_persistent_thickness_without_boundary_geometry() { + let mut command = SectionPlaneCommand::new( + Some((DVec3::ZERO, DVec3::splat(10.0))), + 3, + ); + command.kind = SectionKind::Slice; + let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true); + let (entity, section) = data(&entity); + + assert_eq!(section.state, 1); + assert!(section.back_line_vertices.is_empty()); + assert!(crate::entities::extended::section_is_slice(entity)); + assert!((crate::entities::extended::section_slice_depth(entity).unwrap() - 0.1).abs() + < 1e-12); + } + + #[test] + fn boundary_keeps_a_real_back_line_and_no_slice_marker() { + let mut command = SectionPlaneCommand::new( + Some((DVec3::ZERO, DVec3::splat(10.0))), + 1, + ); + command.kind = SectionKind::Boundary; + let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true); + let (entity, section) = data(&entity); + + assert_eq!(section.state, 2); + assert_eq!(section.back_line_vertices.len(), section.vertices.len()); + assert!(!crate::entities::extended::section_is_slice(entity)); + } +} diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 5095dd680..598627e8c 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -167,7 +167,7 @@ pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } -fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { +pub(crate) fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { let sat = parse_acis( || acis.parse(), acis.is_binary, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 159598d43..02ed14956 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -143,6 +143,106 @@ impl Scene { let _ = self.document.app_ids.add(app_id); } + fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { + use acadrust::entities::ExtendedEntityData; + use acadrust::objects::{ + ClassObject, ClassObjectData, SectionManager, SectionSettings, + SectionTypeSettings, + }; + + let EntityType::Extended(extended) = entity else { + return false; + }; + let ExtendedEntityData::SectionObject(data) = &mut extended.data else { + return false; + }; + + if extended.common.handle.is_null() { + extended.common.handle = self.document.allocate_handle(); + } + let entity_handle = extended.common.handle; + + let valid_settings = matches!( + self.document.objects.get(&data.settings_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionSettings(_)) + ); + if !valid_settings { + let settings_handle = self.document.allocate_handle(); + let mut settings = ClassObject::new(ClassObjectData::SectionSettings( + SectionSettings { + current_type: 4, + types: vec![SectionTypeSettings { + section_type: 4, + generation: 17, + ..SectionTypeSettings::default() + }], + }, + )); + settings.handle = settings_handle; + settings.owner = entity_handle; + self.document + .objects + .insert(settings_handle, ObjectType::ClassObject(settings)); + data.settings_handle = settings_handle; + } else if let Some(ObjectType::ClassObject(settings)) = + self.document.objects.get_mut(&data.settings_handle) + { + settings.owner = entity_handle; + } + + let root = crate::scene::annotative::root_named_dict_handle(&mut self.document); + let manager_handle = self + .document + .objects + .get(&root) + .and_then(|object| match object { + ObjectType::Dictionary(dictionary) => dictionary + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")) + .map(|(_, handle)| *handle), + _ => None, + }) + .filter(|handle| { + matches!( + self.document.objects.get(handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(_)) + ) + }) + .unwrap_or_else(|| { + let handle = self.document.allocate_handle(); + let mut manager = + ClassObject::new(ClassObjectData::SectionManager(SectionManager::default())); + manager.handle = handle; + manager.owner = root; + self.document + .objects + .insert(handle, ObjectType::ClassObject(manager)); + if let Some(ObjectType::Dictionary(dictionary)) = + self.document.objects.get_mut(&root) + { + dictionary.entries.retain(|(name, _)| { + !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER") + }); + dictionary.add_entry("ACAD_SECTION_MANAGER", handle); + } + handle + }); + if let Some(ObjectType::ClassObject(manager)) = + self.document.objects.get_mut(&manager_handle) + { + manager.owner = root; + if let ClassObjectData::SectionManager(manager) = &mut manager.data { + if !manager.sections.contains(&entity_handle) { + manager.sections.push(entity_handle); + } + } + } + true + } + pub fn add_entity(&mut self, entity: EntityType) -> Handle { self.add_entity_internal(entity, true) } @@ -195,6 +295,7 @@ impl Scene { // bookkeeping and remains in place while an entity delta is undone. let mutates_block_structure = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); + let creates_section_structure = self.prepare_section_objects(&mut entity); let hatch_seed = if let EntityType::Hatch(dxf) = &entity { let color = self.render_style(&entity).0; Self::hatch_model_from_dxf(dxf, color) @@ -337,7 +438,11 @@ impl Scene { // state (a new layer, application ID, or block). if self.is_recording_undo() { self.record_undo_before(handle, None); - if creates_layer || creates_app_id || mutates_block_structure { + if creates_layer + || creates_app_id + || mutates_block_structure + || creates_section_structure + { self.poison_undo_recording(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 16d20e907..f136d16e3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2040,6 +2040,53 @@ fn section_frame( Some((origin, tangent, vertical, viewing)) } +#[derive(Clone)] +struct LiveSection { + data: acadrust::entities::SectionObjectData, + slice_depth: Option, +} + +fn section_plane( + origin: glam::DVec3, + tangent: glam::DVec3, + normal: glam::DVec3, + body_to_world: Option<&acadrust::types::Transform>, +) -> Option { + let Some(transform) = body_to_world else { + return cadkernel::space::Plane::orthonormal( + origin.to_array(), + tangent.to_array(), + normal.to_array(), + ); + }; + let matrix = transform.matrix.m; + let linear = acadrust::types::Matrix3::from_rows( + [matrix[0][0], matrix[0][1], matrix[0][2]], + [matrix[1][0], matrix[1][1], matrix[1][2]], + [matrix[2][0], matrix[2][1], matrix[2][2]], + ); + let inverse = linear.inverse()?; + let translation = acadrust::types::Vector3::new( + matrix[0][3], + matrix[1][3], + matrix[2][3], + ); + let origin = inverse.transform_point( + acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation, + ); + let tangent = inverse.transform_point(acadrust::types::Vector3::new( + tangent.x, tangent.y, tangent.z, + )); + let normal = linear + .transpose() + .transform_point(acadrust::types::Vector3::new(normal.x, normal.y, normal.z)); + cadkernel::space::Plane::orthonormal( + [origin.x, origin.y, origin.z], + [tangent.x, tangent.y, tangent.z], + [normal.x, normal.y, normal.z], + ) +} + fn keep_section_positive( body: &cadkernel::brep::Body, plane: cadkernel::space::Plane, @@ -6906,6 +6953,7 @@ impl Scene { annotation_scale_handle, all_visible, viewport, + live_section.as_ref(), )); all } @@ -6913,7 +6961,7 @@ impl Scene { fn active_live_section( &self, target_block: Handle, - ) -> Option { + ) -> Option { self.document .entities() .filter_map(|entity| { @@ -6927,7 +6975,14 @@ impl Scene { (data.flags & 1 != 0 && !extended.common.invisible && (owner.is_null() || owner == target_block)) - .then(|| data.clone()) + .then(|| LiveSection { + data: data.clone(), + slice_depth: crate::entities::extended::section_is_slice(extended) + .then(|| { + crate::entities::extended::section_slice_depth(extended) + .unwrap_or(0.0) + }), + }) }) .last() } @@ -6935,43 +6990,74 @@ impl Scene { fn sectioned_body( &self, handle: Handle, + section: &LiveSection, + ) -> Result, ()> { + let body = self.section_source_body(handle).ok_or(())?; + Self::section_body(&body, §ion.data, section.slice_depth, None) + } + + fn section_source_body( + &self, + handle: Handle, + ) -> Option> { + if let Some(body) = self.solid_models.get(&handle) { + return Some(std::borrow::Cow::Borrowed(body)); + } + let body = match self.document.get_entity(handle) { + 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::Body(body)) => { + crate::scene::convert::solid3d_tess::kernel_acis_body(&body.acis_data) + } + Some(EntityType::Surface(surface)) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } + _ => None, + }?; + Some(std::borrow::Cow::Owned(body)) + } + + fn section_body( + body: &cadkernel::brep::Body, data: &acadrust::entities::SectionObjectData, + slice_depth: Option, + body_to_world: Option<&acadrust::types::Transform>, ) -> Result, ()> { - let body = self.solid_models.get(&handle).ok_or(())?; let Some((origin, tangent, vertical, viewing)) = section_frame(data) else { return Err(()); }; - let front = cadkernel::space::Plane::orthonormal( - origin.to_array(), - tangent.to_array(), - viewing.to_array(), - ) - .ok_or(())?; + let front = section_plane(origin, tangent, viewing, body_to_world).ok_or(())?; let mut clipped = keep_section_positive(body, front)?; let Some(mut current) = clipped.take() else { return Ok(None); }; - if data.state == 1 { + if data.state == 1 && slice_depth.is_none() { return Ok(Some(current)); } - let depth = data - .vertices - .first() - .zip(data.back_line_vertices.first()) - .map_or(0.0, |(front, back)| { - (glam::DVec3::new(back.x, back.y, back.z) - - glam::DVec3::new(front.x, front.y, front.z)) - .dot(viewing) - .abs() - }); + let depth = slice_depth.unwrap_or_else(|| { + data.vertices + .first() + .zip(data.back_line_vertices.first()) + .map_or(0.0, |(front, back)| { + (glam::DVec3::new(back.x, back.y, back.z) + - glam::DVec3::new(front.x, front.y, front.z)) + .dot(viewing) + .abs() + }) + }); if depth <= 1e-12 { return Ok(Some(current)); } - let back = cadkernel::space::Plane::orthonormal( - (origin + viewing * depth).to_array(), - tangent.to_array(), - (-viewing).to_array(), + let back = section_plane( + origin + viewing * depth, + tangent, + -viewing, + body_to_world, ) .ok_or(())?; let Some(next) = keep_section_positive(¤t, back)? else { @@ -6979,6 +7065,10 @@ impl Scene { }; current = next; + if data.state == 1 { + return Ok(Some(current)); + } + let span = data .vertices .first() @@ -6988,25 +7078,41 @@ impl Scene { - glam::DVec3::new(first.x, first.y, first.z)) .length() }); - let bounded = data.state == 4 || (data.state == 2 && depth >= span * 0.25); - if !bounded || span <= 1e-12 { + if span <= 1e-12 { return Ok(Some(current)); } let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; for plane in [ - cadkernel::space::Plane::orthonormal(first.to_array(), vertical.to_array(), tangent.to_array()), - cadkernel::space::Plane::orthonormal(last.to_array(), vertical.to_array(), (-tangent).to_array()), - cadkernel::space::Plane::orthonormal( - (origin - vertical * data.bottom_height).to_array(), - tangent.to_array(), - vertical.to_array(), + section_plane(first, vertical, tangent, body_to_world), + section_plane(last, vertical, -tangent, body_to_world), + ] { + let Some(plane) = plane else { + return Err(()); + }; + let Some(next) = keep_section_positive(¤t, plane)? else { + return Ok(None); + }; + current = next; + } + + if data.state != 4 { + return Ok(Some(current)); + } + + for plane in [ + section_plane( + origin - vertical * data.bottom_height, + tangent, + vertical, + body_to_world, ), - cadkernel::space::Plane::orthonormal( - (origin + vertical * data.top_height).to_array(), - tangent.to_array(), - (-vertical).to_array(), + section_plane( + origin + vertical * data.top_height, + tangent, + -vertical, + body_to_world, ), ] { let Some(plane) = plane else { @@ -7299,6 +7405,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, viewport: Option, + live_section: Option<&LiveSection>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); @@ -7325,9 +7432,37 @@ impl Scene { return; } let handle = entity.common().handle; - let Some(set) = self.block_meshes.get(&handle) else { + let Some(original) = self.block_meshes.get(&handle) else { return; }; + let sectioned; + let set = if let Some((section, body)) = live_section.and_then(|section| { + self.section_source_body(handle) + .map(|body| (section, body)) + }) { + match Self::section_body( + &body, + §ion.data, + section.slice_depth, + Some(&context.transform), + ) { + Ok(Some(body)) => { + if let Some((mut mesh, _, _)) = + self.prepare_solid_model_display(handle, &body) + { + mesh.prepare_instance_source(handle); + sectioned = mesh; + §ioned + } else { + original + } + } + Ok(None) => return, + Err(()) => original, + } + } else { + original + }; let inherit = self.mesh_inherit_for_path(&context.insert_path); let own_alpha = set.display_color().map_or(1.0, |color| color[3]); let mut transformed = @@ -10698,6 +10833,169 @@ impl Default for Scene { } } +#[cfg(test)] +mod section_tests { + use super::*; + use acadrust::entities::{ + EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData, Solid3D, + }; + use acadrust::objects::ClassObjectData; + use acadrust::types::{Color, Vector3}; + + fn section(state: i32, depth: f64) -> SectionObjectData { + let vertices = vec![Vector3::new(5.0, 2.0, 5.0), Vector3::new(5.0, 8.0, 5.0)]; + SectionObjectData { + state, + flags: 5, + name: "Section Plane (1)".to_string(), + vertical_direction: Vector3::UNIT_Z, + top_height: 1.0, + bottom_height: 2.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + back_line_vertices: (depth > 0.0) + .then(|| { + vertices + .iter() + .map(|point| *point + Vector3::new(-depth, 0.0, 0.0)) + .collect() + }) + .unwrap_or_default(), + vertices, + settings_handle: Handle::NULL, + } + } + + fn bounds(body: Option) -> ([f64; 3], [f64; 3]) { + let body = body.expect("section should retain material"); + assert!(body.validate().is_empty()); + let bounds = cadkernel::brep::body_bounds(&body).expect("sectioned body should be bounded"); + (bounds.min, bounds.max) + } + + fn assert_near(actual: [f64; 3], expected: [f64; 3]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}"); + } + } + + #[test] + fn kernel_clipping_obeys_plane_slice_boundary_and_volume_limits() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + + let (min, max) = bounds(Scene::section_body(&body, §ion(1, 0.0), None, None).unwrap()); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + + let (min, max) = bounds( + Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap(), + ); + assert_near(min, [3.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + + let (min, max) = bounds(Scene::section_body(&body, §ion(2, 4.0), None, None).unwrap()); + assert_near(min, [1.0, 2.0, 0.0]); + assert_near(max, [5.0, 8.0, 10.0]); + + let (min, max) = bounds(Scene::section_body(&body, §ion(4, 4.0), None, None).unwrap()); + assert_near(min, [1.0, 2.0, 3.0]); + assert_near(max, [5.0, 8.0, 6.0]); + } + + #[test] + fn instance_transform_pulls_the_section_plane_into_body_space() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + let transform = acadrust::types::Transform::from_scaling( + acadrust::types::Vector3::new(2.0, 1.0, 1.0), + ) + .then(&acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + )); + let mut data = section(1, 0.0); + for point in &mut data.vertices { + point.x = 20.0; + } + + let (min, max) = bounds( + Scene::section_body(&body, &data, None, Some(&transform)).unwrap(), + ); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + } + + #[test] + fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() { + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Extended(ExtendedEntity { + common: EntityCommon::new(), + data: ExtendedEntityData::SectionObject(section(1, 0.0)), + })); + let settings_handle = match scene.document.get_entity(handle).unwrap() { + EntityType::Extended(ExtendedEntity { + data: ExtendedEntityData::SectionObject(data), + .. + }) => data.settings_handle, + _ => unreachable!(), + }; + assert!(matches!( + scene.document.objects.get(&settings_handle), + Some(ObjectType::ClassObject(object)) + if object.owner == handle + && matches!(&object.data, ClassObjectData::SectionSettings(_)) + )); + + let root = scene.document.header.named_objects_dict_handle; + let manager_handle = match scene.document.objects.get(&root).unwrap() { + ObjectType::Dictionary(dictionary) => dictionary + .entries + .iter() + .find(|(name, _)| name == "ACAD_SECTION_MANAGER") + .map(|(_, handle)| *handle) + .unwrap(), + _ => panic!("root must be a dictionary"), + }; + assert!(matches!( + scene.document.objects.get(&manager_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(manager) + if manager.sections == vec![handle]) + )); + + scene.erase_entities(&[handle]); + assert!(!scene.document.objects.contains_key(&settings_handle)); + assert!(matches!( + scene.document.objects.get(&manager_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(manager) + if manager.sections.is_empty()) + )); + } + + #[test] + fn live_section_rebuilds_an_uncached_persisted_solid() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + let sat = crate::scene::convert::acis_export::solid_to_sat(&body).unwrap(); + let mut solid = Solid3D::new(); + solid.set_sat_document(&sat); + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Solid3D(solid)); + assert!(!scene.solid_models.contains_key(&handle)); + + let clipped = scene + .sectioned_body( + handle, + &LiveSection { + data: section(1, 0.0), + slice_depth: None, + }, + ) + .unwrap(); + let (min, max) = bounds(clipped); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + } +} + #[cfg(test)] mod journal_tests { use super::*; diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 868595fe4..42e6e8888 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -938,12 +938,57 @@ impl Scene { if respect_layer_locks && self.is_layer_locked(h) { continue; } + let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { + EntityType::Extended(acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(data), + .. + }) if !data.settings_handle.is_null() => Some(data.settings_handle), + _ => None, + }); + let section_managers: Vec = self + .document + .objects + .iter() + .filter_map(|(handle, object)| match object { + ObjectType::ClassObject(object) => match &object.data { + acadrust::objects::ClassObjectData::SectionManager(manager) + if manager.sections.contains(&h) => + { + Some(*handle) + } + _ => None, + }, + _ => None, + }) + .collect(); // Delta-undo: capture the removed entity so an undo can re-insert it. if self.is_recording_undo() { let before = self.document.get_entity_arc(h); self.record_undo_before(h, before); + if let Some(settings_handle) = settings_handle { + let before = self.document.objects.get(&settings_handle).cloned(); + self.record_undo_object_before(settings_handle, before); + } + for &manager_handle in §ion_managers { + let before = self.document.objects.get(&manager_handle).cloned(); + self.record_undo_object_before(manager_handle, before); + } } self.delete_solid_history(h); + if let Some(settings_handle) = settings_handle { + self.document.objects.remove(&settings_handle); + } + for manager_handle in section_managers { + if let Some(ObjectType::ClassObject(object)) = + self.document.objects.get_mut(&manager_handle) + { + if let acadrust::objects::ClassObjectData::SectionManager(manager) = + &mut object.data + { + manager.sections.retain(|section| *section != h); + } + } + } self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); selection_changed |= self.selected.remove(&h); diff --git a/tests/section_plane_roundtrip.rs b/tests/section_plane_roundtrip.rs new file mode 100644 index 000000000..2f5307815 --- /dev/null +++ b/tests/section_plane_roundtrip.rs @@ -0,0 +1,110 @@ +use acadrust::entities::{EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData}; +use acadrust::objects::{ClassObjectData, ObjectType}; +use acadrust::types::{Color, Handle, Vector3}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; +use acadrust::EntityType; +use OpenCADStudio::scene::Scene; + +fn add_slice(scene: &mut Scene) -> Handle { + let mut common = EntityCommon::new(); + let mut slice = ExtendedDataRecord::new("IsSlice"); + slice.values = vec![XDataValue::Integer16(0), XDataValue::Integer16(1)]; + common.extended_data.add_record(slice); + let mut thickness = ExtendedDataRecord::new("ThicknessDepth"); + thickness.values = vec![XDataValue::Real(0.75)]; + common.extended_data.add_record(thickness); + + scene.add_entity(EntityType::Extended(ExtendedEntity { + common, + data: ExtendedEntityData::SectionObject(SectionObjectData { + state: 1, + flags: 5, + name: "Section Plane (1)".to_string(), + vertical_direction: Vector3::UNIT_Z, + top_height: 5.0, + bottom_height: 5.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + vertices: vec![Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0)], + back_line_vertices: Vec::new(), + settings_handle: Handle::NULL, + }), + })) +} + +fn assert_section_graph(document: &acadrust::CadDocument, extension: &str) { + let (handle, section) = document + .entities() + .find_map(|entity| match entity { + EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::SectionObject(_) => Some((extended.common.handle, extended)), + _ => None, + }, + _ => None, + }) + .unwrap_or_else(|| panic!("section entity should round-trip through {extension}")); + let ExtendedEntityData::SectionObject(data) = §ion.data else { + unreachable!(); + }; + assert_eq!(data.state, 1); + assert!(data.back_line_vertices.is_empty()); + assert!(matches!( + section + .common + .extended_data + .get_record("IsSlice") + .map(|record| record.values.as_slice()), + Some([XDataValue::Integer16(0), XDataValue::Integer16(1)]) + )); + assert!(matches!( + section + .common + .extended_data + .get_record("ThicknessDepth") + .and_then(|record| record.values.first()), + Some(XDataValue::Real(value)) if (*value - 0.75).abs() < 1e-12 + )); + assert!(matches!( + document.objects.get(&data.settings_handle), + Some(ObjectType::ClassObject(object)) + if object.owner == handle + && matches!(&object.data, ClassObjectData::SectionSettings(_)) + )); + + let root = document + .objects + .get(&document.header.named_objects_dict_handle) + .expect("root dictionary should round-trip"); + let ObjectType::Dictionary(root) = root else { + panic!("root should be a dictionary"); + }; + let manager_handle = root + .entries + .iter() + .find(|(name, _)| name == "ACAD_SECTION_MANAGER") + .map(|(_, handle)| *handle) + .expect("section manager should be registered"); + let Some(ObjectType::ClassObject(manager)) = document.objects.get(&manager_handle) else { + panic!("section manager handle should resolve"); + }; + let ClassObjectData::SectionManager(manager) = &manager.data else { + panic!("registered object should be a section manager"); + }; + assert_eq!(manager.sections, vec![handle]); +} + +#[test] +fn section_plane_graph_survives_dxf_and_dwg_roundtrips() { + let mut scene = Scene::new(); + add_slice(&mut scene); + + for extension in ["dxf", "dwg"] { + let bytes = + OpenCADStudio::io::save_to_bytes(&scene.document, extension, scene.document.version) + .expect("section document should save"); + let document = + OpenCADStudio::io::load_bytes(&format!("section-roundtrip.{extension}"), bytes) + .expect("section document should reload"); + assert_section_graph(&document, extension); + } +}