From 27bf35fc9673dd0c9689e554f6ef62442b1f84d1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:31:58 +0300 Subject: [PATCH 1/9] Associative extruded, revolved and lofted surfaces; spline and ellipse sources SURFACEASSOCIATIVITY now keeps EXTRUDE, REVOLVE and LOFT surfaces linked to their sources: the profile (and axis object) or cross sections are kept, the surface is made again when they change, and the drawing's associative network carries an action with the extruded, revolved or lofted surface action body, its path parameters and value parameters as the reference writes them. Swept, extruded, revolved and lofted surfaces can read splines (NURBS edge curve, fit-point splines through their fit points) and ellipses (elliptical edge curve). Associative surfaces of a file opened here follow their sources too. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/solid3d.rs | 70 +++++++- src/command.rs | 3 + src/modules/insert/solid3d_cmds.rs | 10 +- src/scene/dwg_native_constraints.rs | 230 ++++++++++++++++++++++++- src/scene/mod.rs | 5 + src/scene/model/loft_command_model.rs | 2 +- src/scene/model/mod.rs | 1 + src/scene/model/surface_sources.rs | 121 +++++++++++++ src/scene/model/sweep_command_model.rs | 54 ++++++ src/scene/modify.rs | 190 ++++++++++++++++++-- 10 files changed, 661 insertions(+), 25 deletions(-) create mode 100644 src/scene/model/surface_sources.rs diff --git a/src/app/command_driver/solid3d.rs b/src/app/command_driver/solid3d.rs index 5042fb036..05b01a213 100644 --- a/src/app/command_driver/solid3d.rs +++ b/src/app/command_driver/solid3d.rs @@ -42,6 +42,7 @@ impl OpenCADStudio { } let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true); let mut created_handles = Vec::new(); + let mut associative_sources = Vec::new(); let mut consumed = Vec::new(); let mut failed = 0usize; for handle in &handles { @@ -125,12 +126,27 @@ impl OpenCADStudio { entity.surface_data = data; } } - match history { + let created = match history { Some(history) => { self.add_surface_model_with_history(surface, body, history) } None => self.add_surface_model(surface, body), + }; + // An associative surface keeps reading its profile. + if sweep_model::surface_associativity() && path.is_none() && !created.is_null() { + use crate::scene::model::surface_sources::{link_surface_sources, SurfaceSources}; + let (height, given) = match extent { + ExtrudeExtent::Height(height) => (height, None), + _ => (direction.length(), Some(direction)), + }; + link_surface_sources( + &mut self.tabs[i].scene.document, + created, + &SurfaceSources::Extrude { profile: *handle, height, direction: given, taper: taper_angle }, + ); + associative_sources.push(*handle); } + created } else { let direction = direction.unwrap_or(glam::DVec3::ZERO); let history = path @@ -159,7 +175,7 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_sources { + if delete_sources && !associative_sources.contains(handle) { consumed.push(*handle); } } @@ -319,6 +335,7 @@ impl OpenCADStudio { handles, axis_start, axis_end, + axis_object, angle, start_angle, mode, @@ -355,6 +372,7 @@ impl OpenCADStudio { self.begin_undo(i, "REVOLVE", editable_handles.len(), true) }; let mut created_handles = Vec::new(); + let mut associative_sources = Vec::new(); let mut consumed = Vec::new(); for handle in &editable_handles { let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() @@ -374,7 +392,7 @@ impl OpenCADStudio { failed += 1; continue; }; - self.add_surface_model( + let created = self.add_surface_model( empty_revolved_surface( &entity, axis_start, @@ -383,7 +401,25 @@ impl OpenCADStudio { start_angle, ), body, - ) + ); + // An associative surface keeps reading its profile and axis. + if sweep_model::surface_associativity() && !created.is_null() { + use crate::scene::model::surface_sources::{link_surface_sources, SurfaceSources}; + link_surface_sources( + &mut self.tabs[i].scene.document, + created, + &SurfaceSources::Revolve { + profile: *handle, + axis: axis_object, + start: axis_start, + end: axis_end, + angle, + start_angle, + }, + ); + associative_sources.push(*handle); + } + created } else { let result = sweep_model::revolve_history(&entity, from, to, angle, start_angle) @@ -410,7 +446,7 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_sources { + if delete_sources && !associative_sources.contains(handle) { consumed.push(*handle); } } @@ -678,6 +714,7 @@ impl OpenCADStudio { } let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); + let mut associative = false; let created = if surface { let handle = self.add_surface_model( loft_command_model::surface_entity(&record), @@ -688,6 +725,27 @@ impl OpenCADStudio { handle, codec::objects::SolidHistoryOperation::Loft(record), ); + // An associative surface keeps reading its cross + // sections (each section a single object). + let single: Option> = sections + .iter() + .map(|section| match section { + LoftSectionSelection::Entity(handle) => Some(*handle), + _ => None, + }) + .collect(); + if let Some(single) = single.filter(|_| { + crate::scene::model::sweep_model::surface_associativity() + && guides.is_empty() + && path.is_none() + }) { + crate::scene::model::surface_sources::link_surface_sources( + &mut self.tabs[i].scene.document, + handle, + &crate::scene::model::surface_sources::SurfaceSources::Loft { sections: single }, + ); + associative = true; + } } handle } else { @@ -705,7 +763,7 @@ impl OpenCADStudio { self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); return Some(Task::none()); } else { - let mut consumed = if delete_sections { + let mut consumed = if delete_sections && !associative { sources.clone() } else { Vec::new() diff --git a/src/command.rs b/src/command.rs index feb9be7f4..186874542 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2092,6 +2092,9 @@ pub enum CmdResult { handles: Vec, axis_start: glam::DVec3, axis_end: glam::DVec3, + /// The line picked as the axis (Object), which an associative + /// surface keeps reading. + axis_object: Option, angle: f64, start_angle: f64, mode: ExtrudeMode, diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index feaa80e1f..43057bb40 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1064,6 +1064,7 @@ pub struct RevolveCommand { handles: Vec, preview_profiles: Vec<(Handle, EntityType)>, injected_axis: Option, + axis_object: Option, axis_start: DVec3, axis_end: DVec3, working_plane: WorkingPlane, @@ -1114,6 +1115,7 @@ impl RevolveCommand { handles: Vec::new(), preview_profiles: Vec::new(), injected_axis: None, + axis_object: None, axis_start: DVec3::ZERO, axis_end: DVec3::new(0.0, 0.0, 1.0), working_plane: WorkingPlane::default(), @@ -1253,6 +1255,7 @@ impl RevolveCommand { }; self.axis_start = self.working_plane.origin; self.axis_end = self.axis_start + direction; + self.axis_object = None; self.step = RevolveStep::Angle; CmdResult::NeedPoint } @@ -1269,6 +1272,7 @@ impl RevolveCommand { handles: self.handles.clone(), axis_start: self.axis_start, axis_end: self.axis_end, + axis_object: self.axis_object, angle, start_angle: self.start_angle, mode: self.mode, @@ -1352,12 +1356,13 @@ impl CadCommand for RevolveCommand { } match self.step { RevolveStep::AxisObject => { - let Some((start, end)) = self.injected_axis.take().as_ref().and_then(revolve_axis) - else { + let axis = self.injected_axis.take(); + let Some((start, end)) = axis.as_ref().and_then(revolve_axis) else { return CmdResult::NeedPoint; }; self.axis_start = start; self.axis_end = end; + self.axis_object = matches!(axis, Some(EntityType::Line(_))).then_some(handle); self.step = RevolveStep::Angle; CmdResult::NeedPoint } @@ -1368,6 +1373,7 @@ impl CadCommand for RevolveCommand { match self.step { RevolveStep::AxisStart => { self.axis_start = pt; + self.axis_object = None; self.step = RevolveStep::AxisEnd; CmdResult::NeedPoint } diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index f2779fa26..d3074f691 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2657,6 +2657,225 @@ fn pending_swept_surfaces(document: &CadDocument) -> Vec<(Handle, Handle, Handle pending } +/// Associative extruded, revolved and lofted surfaces made here that the +/// drawing's network does not carry yet, with their sources. +fn pending_source_surfaces( + document: &CadDocument, +) -> Vec<(Handle, crate::scene::model::surface_sources::SurfaceSources)> { + use crate::scene::model::{surface_sources, sweep_model}; + let carried: Vec = document + .objects + .keys() + .filter_map(|handle| action_surface(document, *handle)) + .collect(); + let mut pending = document + .entities() + .filter(|entity| matches!(entity, codec::EntityType::Surface(_))) + .filter_map(|entity| { + let sources = surface_sources::surface_sources(entity)?; + let surface = entity.common().handle; + (!carried.contains(&surface) + && sources.handles().iter().all(|handle| { + document.get_entity(*handle).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some()) + })) + .then_some((surface, sources)) + }) + .collect::>(); + pending.sort_by_key(|(surface, _)| surface.value()); + pending +} + +/// Whether the drawing has associative surfaces its network has to carry. +fn has_pending_surfaces(document: &CadDocument) -> bool { + !pending_swept_surfaces(document).is_empty() || !pending_source_surfaces(document).is_empty() +} + +/// The reference's associative network for each pending extruded, revolved +/// or lofted surface: an action with the matching surface action body that +/// writes the surface and reads its sources through path parameters (an +/// empty one for a revolve axis given by points and for a loft without a +/// path), with the surface's values as value parameters. Returns the +/// network's new actions in order. +fn materialize_source_surface_actions( + allocator: &mut Allocator, + network: Handle, + pending: &[(Handle, crate::scene::model::surface_sources::SurfaceSources)], + first_index: i32, +) -> Vec { + use crate::scene::model::surface_sources::SurfaceSources; + use crate::scene::model::sweep_model; + use codec::objects::*; + let mut added = Vec::new(); + for (surface, sources) in pending.iter() { + let surface = *surface; + let document = &*allocator.document; + // The value parameters: name, unit type, values, and whether they + // control the surface. + let real = |value: f64| AssocEvalVariant { code: 40, value: AssocEvalValue::Real(value) }; + let short = |value: i32| AssocEvalVariant { code: 70, value: AssocEvalValue::Short(value as i16) }; + let (kind, class, dxf, values, params): ( + AssocSurfaceActionKind, + &str, + &str, + Vec<(&str, i32, Vec, bool)>, + Vec<(&str, Option)>, + ) = match sources { + SurfaceSources::Extrude { profile, height, taper, .. } => ( + AssocSurfaceActionKind::Extruded, + "AcDbAssocExtrudedSurfaceActionBody", + "ACDBASSOCEXTRUDEDSURFACEACTIONBODY", + vec![("ExtrusionHeight", 1, vec![real(*height)], true), ("ExtrusionTaperAngle", 2, vec![real(*taper)], true)], + vec![("ExtrudeProfile", Some(*profile))], + ), + SurfaceSources::Revolve { profile, axis, angle, .. } => ( + AssocSurfaceActionKind::Revolved, + "AcDbAssocRevolvedSurfaceActionBody", + "ACDBASSOCREVOLVEDSURFACEACTIONBODY", + vec![("RevolveAngle", 2, vec![real(*angle)], true)], + vec![("RevolveProfile", Some(*profile)), ("RevolvePath", *axis)], + ), + SurfaceSources::Loft { sections } => { + let loft = document.solid_history_operations(surface).and_then(|operations| { + operations.into_iter().find_map(|operation| match operation { + SolidHistoryOperation::Loft(record) => record.parameters, + _ => None, + }) + }); + let (continuity, bulge) = loft.map_or(((1, 1), (0.5, 0.5)), |p| { + ((p.start_continuity, p.end_continuity), (p.start_bulge, p.end_bulge)) + }); + ( + AssocSurfaceActionKind::Lofted, + "AcDbAssocLoftedSurfaceActionBody", + "ACDBASSOCLOFTEDSURFACEACTIONBODY", + vec![ + ("Continuity", 0, vec![short(continuity.0), short(continuity.1)], false), + ("Bulge", 0, vec![real(bulge.0), real(bulge.1)], false), + ], + sections.iter().map(|section| ("CrossSection", Some(*section))) + .chain(std::iter::once(("PathCurve", None))) + .collect(), + ) + } + }; + let curves = params + .iter() + .map(|(_, source)| match source { + Some(source) => document.get_entity(*source).and_then(sweep_model::assoc_edge_curve).map(Some), + None => Some(None), + }) + .collect::>>(); + let Some(curves) = curves else { continue }; + let document = &mut *allocator.document; + let (action, body, written) = (document.allocate_handle(), document.allocate_handle(), document.allocate_handle()); + let dependency = |dependent_on: Handle, read: bool, id: i32, order: i32| AssocDependency { + class_version: 2, + status: 0, + is_read_dependency: read, + is_write_dependency: !read, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + order, + dependent_on, + name: None, + read_dependency: Handle::NULL, + node: Handle::NULL, + dependency_body: Handle::NULL, + dependency_body_id: id, + }; + let mut objects: Vec<(Handle, Handle, &str, &str, AssociativeData)> = Vec::new(); + let mut reactors: Vec<(Handle, Handle)> = vec![(surface, written)]; + let mut dependencies = vec![written]; + let mut owned = Vec::new(); + objects.push((written, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(surface, false, 1, i32::MIN)))); + for ((name, source), curve) in params.iter().zip(curves) { + let document = &mut *allocator.document; + let parameter = document.allocate_handle(); + let mut children = Vec::new(); + if let (Some(source), Some((action_type, subcurve_kind, curve))) = (source, curve) { + let (edge, read) = (document.allocate_handle(), document.allocate_handle()); + objects.push((edge, parameter, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", + AssociativeData::EdgeActionParam(AssocEdgeActionParam { + single_dependency: AssocSingleDependencyActionParam { + action_param: AssocActionParam { is_r2013: 1, version: 0, name: String::new() }, + dependency_class_version: 0, + dependency: read, + class_version: 0, + }, + parameter: Handle::NULL, + has_action: true, + action_type, + subcurve_kind, + curve, + }))); + objects.push((read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", + AssociativeData::Dependency(dependency(*source, true, dependencies.len() as i32 + 1, 0)))); + reactors.push((*source, read)); + dependencies.push(read); + children.push(edge); + } + objects.push((parameter, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", + AssociativeData::PathActionParam(AssocPathActionParam { + compound: AssocCompoundActionParam { + action_param: AssocActionParam { is_r2013: 1, version: 0, name: name.to_string() }, + class_version: 0, + status: 0, + parameters: children, + child_parameter: None, + }, + version: 0, + }))); + owned.push(parameter); + } + objects.push((action, network, "ACDBASSOCACTION", "AcDbAssocAction", AssociativeData::Action(AssocAction { + class_version: 2, + geometry_status: 0, + owning_network: network, + action_body: body, + action_index: 0, + max_dependency_index: dependencies.len() as i32, + dependencies: dependencies.iter().map(|handle| AssocActionDependency { is_owned: true, dependency: *handle }).collect(), + owned_parameters: owned, + values: values + .into_iter() + .map(|(name, unit_type, amounts, controls)| AssocValueParam { + class_version: 0, + name: name.to_string(), + unit_type, + variables: amounts.into_iter().map(|value| AssocValueParamVariable { value, handle: Handle::NULL }).collect(), + controlled_object_dependency: if controls { written } else { Handle::NULL }, + }) + .collect(), + }))); + objects.push((body, action, dxf, class, AssociativeData::SurfaceActionBody(AssocSurfaceActionBody { + kind, + action_body: AssocActionBody { version: 2 }, + surface_body: AssocSurfaceBody { version: 0, dependency: written, is_semi_associative: false, marker: 1, is_semi_override: false, grip_status: 0 }, + ..Default::default() + }))); + for (handle, owner, dxf, class, data) in objects { + allocator.insert_associative_at(handle, owner, dxf, class, data); + } + for (target, reactor) in reactors { + let document = &mut *allocator.document; + if let Some(entity) = document.get_entity_mut(target) { + entity.common_mut().reactors.push(reactor); + } else if let Some(ObjectType::Associative(object)) = document.objects.get_mut(&target) { + object.reactors.push(reactor); + } + } + added.push(action); + } + for (offset, handle) in added.iter().copied().enumerate() { + if let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::Action(action), .. })) = + allocator.document.objects.get_mut(&handle) + { + action.action_index = first_index + offset as i32 + 1; + } + } + added +} + /// The names an expression reads. fn expression_names(expression: &str) -> Vec { let mut names = Vec::new(); @@ -3005,7 +3224,7 @@ fn materialize_scope( && existing_variables.is_empty() && preserved_groups.is_empty() && foreign_actions.is_empty() - && !(materialize_all_parameters && !pending_swept_surfaces(document).is_empty()) + && !(materialize_all_parameters && has_pending_surfaces(document)) { return None; } @@ -3191,8 +3410,13 @@ fn materialize_scope( } refresh_foreign_values(allocator.document, &foreign_actions, parameters); let pending = if materialize_all_parameters { pending_swept_surfaces(allocator.document) } else { Vec::new() }; - let surface_actions = materialize_swept_surface_actions( + let mut surface_actions = materialize_swept_surface_actions( &mut allocator, network_handle, parameters, &pending, first_foreign + foreign_actions.len() as i32); + let pending_sources = if materialize_all_parameters { pending_source_surfaces(allocator.document) } else { Vec::new() }; + let source_actions = materialize_source_surface_actions( + &mut allocator, network_handle, &pending_sources, + first_foreign + (foreign_actions.len() + surface_actions.len()) as i32); + surface_actions.extend(source_actions); let network = AssocNetwork { action: AssocAction { @@ -3352,7 +3576,7 @@ impl Scene { if set.is_none() && !existing && (owner != model_owner - || (self.named_parameters.is_empty() && pending_swept_surfaces(&self.document).is_empty())) + || (self.named_parameters.is_empty() && !has_pending_surfaces(&self.document))) { continue; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bb92874c9..25d998ccf 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3701,6 +3701,11 @@ impl Scene { changes.push(change); } } + for change in self.refresh_associative_surfaces(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } if solve_parametric && !self.parametric_constraints.is_empty() { for change in self.refresh_parametric_constraints_with_initial_policy( &changes, diff --git a/src/scene/model/loft_command_model.rs b/src/scene/model/loft_command_model.rs index aa671e172..9577096a8 100644 --- a/src/scene/model/loft_command_model.rs +++ b/src/scene/model/loft_command_model.rs @@ -7,7 +7,7 @@ use kernel::brep::Body; use crate::command::{ExtrudeMode, LoftOptions, LoftSectionSelection}; use super::sweep_model::embedded_path; -fn embedded_section(entity: &EntityType) -> Option { +pub(crate) fn embedded_section(entity: &EntityType) -> Option { match entity { EntityType::Region(region) => Some(EmbeddedEntity::Region(region.clone())), EntityType::Point(point) => Some(EmbeddedEntity::Point(point.clone())), diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index 5206cdd5b..e3547b3f1 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -17,6 +17,7 @@ pub mod solid_history; pub mod sweep_model; pub mod presspull_model; pub mod loft_command_model; +pub mod surface_sources; mod sweep_command_model; pub mod visual_style_model; pub mod object; diff --git a/src/scene/model/surface_sources.rs b/src/scene/model/surface_sources.rs new file mode 100644 index 000000000..64945a93a --- /dev/null +++ b/src/scene/model/surface_sources.rs @@ -0,0 +1,121 @@ +//! Associative extruded, revolved and lofted surfaces (SURFACEASSOCIATIVITY): +//! the sources and values each surface was made from, kept with the surface +//! so it follows edits of its sources and the drawing's associative network +//! describes it on save. + +use codec::xdata::XDataValue; +use codec::{EntityType, Handle}; +use glam::DVec3; + +/// The application name under which a surface keeps its sources. +pub const SURFACE_SOURCES_APP: &str = "OCS_SURFACE_SOURCES"; + +/// What an associative surface was made from. +#[derive(Clone, Debug, PartialEq)] +pub enum SurfaceSources { + /// EXTRUDE: the profile, the height along its normal (or the length of a + /// given direction) and the taper angle. + Extrude { profile: Handle, height: f64, direction: Option, taper: f64 }, + /// REVOLVE: the profile, the axis (an axis object or two points), the + /// angle and the start angle. + Revolve { profile: Handle, axis: Option, start: DVec3, end: DVec3, angle: f64, start_angle: f64 }, + /// LOFT: the cross sections in order. + Loft { sections: Vec }, +} + +impl SurfaceSources { + /// Every entity the surface reads. + pub fn handles(&self) -> Vec { + match self { + SurfaceSources::Extrude { profile, .. } => vec![*profile], + SurfaceSources::Revolve { profile, axis, .. } => std::iter::once(*profile).chain(*axis).collect(), + SurfaceSources::Loft { sections } => sections.clone(), + } + } +} + +/// Keep the sources with the surface. +pub fn link_surface_sources(document: &mut codec::CadDocument, surface: Handle, sources: &SurfaceSources) { + if surface.is_null() { + return; + } + let point = |p: DVec3| XDataValue::Point3D(codec::types::Vector3::new(p.x, p.y, p.z)); + let values = match sources { + SurfaceSources::Extrude { profile, height, direction, taper } => { + let mut values = vec![ + XDataValue::String("EXTRUDE".into()), + XDataValue::Handle(*profile), + XDataValue::Real(*height), + XDataValue::Real(*taper), + ]; + values.extend(direction.map(point)); + values + } + SurfaceSources::Revolve { profile, axis, start, end, angle, start_angle } => { + let mut values = vec![ + XDataValue::String("REVOLVE".into()), + XDataValue::Handle(*profile), + point(*start), + point(*end), + XDataValue::Real(*angle), + XDataValue::Real(*start_angle), + ]; + values.extend(axis.map(XDataValue::Handle)); + values + } + SurfaceSources::Loft { sections } => std::iter::once(XDataValue::String("LOFT".into())) + .chain(sections.iter().map(|handle| XDataValue::Handle(*handle))) + .collect(), + }; + crate::scene::view::dispatch::set_entity_xdata(document, surface, SURFACE_SOURCES_APP, Some(values)); +} + +/// The sources an associative surface keeps. +pub fn surface_sources(entity: &EntityType) -> Option { + let record = entity + .common() + .extended_data + .records() + .iter() + .find(|record| record.application_name == SURFACE_SOURCES_APP)?; + let mut kind = None; + let (mut handles, mut reals, mut points) = (Vec::new(), Vec::new(), Vec::new()); + for value in &record.values { + match value { + XDataValue::String(text) if kind.is_none() => kind = Some(text.as_str()), + XDataValue::Handle(handle) => handles.push(*handle), + XDataValue::Real(value) => reals.push(*value), + XDataValue::Point3D(p) => points.push(DVec3::new(p.x, p.y, p.z)), + _ => {} + } + } + match kind? { + "EXTRUDE" => Some(SurfaceSources::Extrude { + profile: *handles.first()?, + height: *reals.first()?, + taper: *reals.get(1)?, + direction: points.first().copied(), + }), + "REVOLVE" => Some(SurfaceSources::Revolve { + profile: *handles.first()?, + axis: handles.get(1).copied(), + start: *points.first()?, + end: *points.get(1)?, + angle: *reals.first()?, + start_angle: *reals.get(1)?, + }), + "LOFT" => (handles.len() >= 2).then_some(SurfaceSources::Loft { sections: handles }), + _ => None, + } +} + +/// The two ends of an axis object (a line). +pub fn axis_points(entity: &EntityType) -> Option<(DVec3, DVec3)> { + match entity { + EntityType::Line(line) => Some(( + DVec3::new(line.start.x, line.start.y, line.start.z), + DVec3::new(line.end.x, line.end.y, line.end.z), + )), + _ => None, + } +} diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index 5d68a3994..6847c980c 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -525,6 +525,60 @@ pub fn assoc_edge_curve(entity: &EntityType) -> Option<(i32, codec::objects::Ass } Some((47, K::None, values)) } + // Ellipse (17): centre, unit major axis, unit minor axis (the normal + // across the major one), major and minor radius, start and end + // parameter, 0. + EntityType::Ellipse(ellipse) => { + let major = glam::DVec3::new(ellipse.major_axis.x, ellipse.major_axis.y, ellipse.major_axis.z); + let normal = glam::DVec3::new(ellipse.normal.x, ellipse.normal.y, ellipse.normal.z).try_normalize()?; + let radius = major.length(); + let axis = major.try_normalize()?; + Some((17, K::Ellipse, vec![ + point(glam::DVec3::new(ellipse.center.x, ellipse.center.y, ellipse.center.z)), + point(axis), + point(normal.cross(axis)), + V::Real(radius), + V::Real(radius * ellipse.minor_axis_ratio), + V::Real(ellipse.start_parameter), + V::Real(ellipse.end_parameter), + V::Real(0.0), + ])) + } + // NURBS (42): rational flag, a second flag (0), degree, knot + // tolerance, then knots, weights (empty unless rational) and control + // points, each as its length, the length reserved (at least 8) and the + // growth step 8, followed by the items. + EntityType::Spline(spline) => { + // A fit-point spline as the curve through its points, its knots + // in the fit parameter (chord length) as the reference keeps them. + let curve = crate::entities::spline::nurbs3(spline)?; + let mut knots = curve.knots().to_vec(); + if crate::entities::spline::uses_fit_method(spline) && spline.knot_parameterization == 0 { + let chord: f64 = spline.fit_points.windows(2) + .map(|pair| ((pair[1].x - pair[0].x).powi(2) + (pair[1].y - pair[0].y).powi(2) + (pair[1].z - pair[0].z).powi(2)).sqrt()) + .sum(); + let (low, high) = (knots[0], knots[knots.len() - 1]); + if high - low > 0.0 && chord > 0.0 && (high - low - chord).abs() > 1e-9 * chord { + knots.iter_mut().for_each(|knot| *knot = (*knot - low) / (high - low) * chord); + } + } + let points = curve.control_points(); + if points.len() < 2 || knots.is_empty() { return None; } + let rational = curve.weights().iter().any(|weight| (weight - 1.0).abs() > 1e-12); + let header = |values: &mut Vec, count: usize| { + values.extend([V::Int(count as i32), V::Int(if count == 0 { 0 } else { count.max(8) } as i32), V::Int(8)]); + }; + let tolerance = if spline.knot_tolerance > 0.0 { spline.knot_tolerance } else { 1e-9 }; + let mut values = vec![V::Bool(rational), V::Bool(false), V::Int(curve.degree() as i32), V::Real(tolerance)]; + header(&mut values, knots.len()); + values.extend(knots.iter().map(|knot| V::Real(*knot))); + let weights = if rational { curve.weights() } else { &[] }; + header(&mut values, weights.len()); + values.extend(weights.iter().map(|weight| V::Real(*weight))); + header(&mut values, points.len()); + values.extend(points.iter().map(|p| V::Point(P::new(p[0], p[1], p[2])))); + Some((42, K::Nurb3d, values)) + } _ => None, } } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index dbde57ece..a10984fc2 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1178,6 +1178,98 @@ impl Scene { rebuilt } + /// Associative extruded, revolved and lofted surfaces follow edits of + /// their sources: made again from them with the values they were made + /// with. A surface edited along with its sources keeps its own edit. + /// Returns the surfaces rebuilt. + pub(crate) fn refresh_associative_surfaces(&mut self, changes: &[(Handle, crate::scene::ChangeKind)]) -> Vec<(Handle, crate::scene::ChangeKind)> { + use crate::scene::model::surface_sources::{axis_points, surface_sources, SurfaceSources, SURFACE_SOURCES_APP}; + use crate::scene::model::sweep_model; + use codec::objects::SolidHistoryOperation; + let changed = changes.iter() + .filter(|(_, kind)| !matches!(kind, crate::scene::ChangeKind::Removed)) + .map(|(handle, _)| *handle) + .collect::>(); + if changed.is_empty() || !self.document.app_ids.contains(SURFACE_SOURCES_APP) { + return Vec::new(); + } + let linked = self.document.entities() + .filter(|entity| matches!(entity, EntityType::Surface(_))) + .filter_map(|entity| { + let handle = entity.common().handle; + let sources = surface_sources(entity)?; + (!changed.contains(&handle) && sources.handles().iter().any(|source| changed.contains(source))) + .then_some((handle, sources)) + }) + .collect::>(); + let mut rebuilt = Vec::new(); + for (handle, sources) in linked { + let source = |handle: Handle| self.document.get_entity(handle).cloned(); + // The surface made again: a history step, or a body with its data. + let made: Option> = match &sources { + SurfaceSources::Extrude { profile, height, direction, taper } => (|| { + let entity = source(*profile)?; + let (shape, _) = sweep_model::extrusion_profile_of(&entity)?; + let direction = match direction { + Some(direction) => *direction, + None => glam::DVec3::from_array(shape.plane.normal()?) * *height, + }; + if self.document.solid_history_operations(handle).is_some() { + return sweep_model::extrusion_history(&entity, None, direction.to_array(), *taper, shape.plane.origin).map(Ok); + } + let body = sweep_model::extruded_surface(&entity, direction.to_array(), *taper)?; + let EntityType::Surface(fresh) = crate::modules::insert::solid3d_cmds::empty_extruded_surface(direction, *taper) else { return None }; + Some(Err((body, fresh.surface_data))) + })(), + SurfaceSources::Revolve { profile, axis, start, end, angle, start_angle } => (|| { + let entity = source(*profile)?; + let (start, end) = axis + .and_then(|axis| self.document.get_entity(axis).and_then(axis_points)) + .unwrap_or((*start, *end)); + let body = sweep_model::revolved_surface(&entity, start.to_array(), end.to_array(), *angle, *start_angle)?; + let EntityType::Surface(fresh) = crate::modules::insert::solid3d_cmds::empty_revolved_surface(&entity, start, end, *angle, *start_angle) else { return None }; + Some(Err((body, fresh.surface_data))) + })(), + SurfaceSources::Loft { sections } => (|| { + let operations = self.document.solid_history_operations(handle)?; + let mut record = operations.into_iter().find_map(|operation| match operation { + SolidHistoryOperation::Loft(record) => Some(record), + _ => None, + })?; + if record.cross_sections.len() != sections.len() { + return None; + } + record.cross_sections = sections.iter() + .map(|section| source(*section).as_ref().and_then(crate::scene::model::loft_command_model::embedded_section)) + .collect::>>()?; + Some(Ok(SolidHistoryOperation::Loft(record))) + })(), + }; + let before = self.document.get_entity(handle).cloned().map(std::sync::Arc::new); + match made { + Some(Ok(operation)) => { + self.record_undo_before(handle, before); + if self.rebuild_solid_history(handle, operation) { + rebuilt.push((handle, crate::scene::ChangeKind::Modified)); + } + } + Some(Err((body, data))) => { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { continue }; + let Some(display) = self.prepare_solid_model_display(handle, &body) else { continue }; + self.record_undo_before(handle, before); + if let Some(EntityType::Surface(surface)) = self.document.get_entity_mut(handle) { + surface.acis_data = codec::entities::AcisData::from_sat(&document.to_sat_string()); + surface.surface_data = data; + } + self.register_prepared_solid_model(handle, body, display); + rebuilt.push((handle, crate::scene::ChangeKind::Modified)); + } + None => {} + } + } + rebuilt + } + /// Swept surfaces the reference keeps associative to a scale or twist /// expression (an associative swept surface action whose value parameter /// depends on an expression variable) are linked the same way here. @@ -1189,12 +1281,57 @@ impl Scene { }; let mut links = Vec::new(); let mut sources = Vec::new(); + let mut others = Vec::new(); for item in self.document.objects.values() { let ObjectType::Associative(action) = item else { continue }; let AssociativeData::Action(action) = &action.data else { continue }; let Some(AssociativeData::SurfaceActionBody(body)) = object(action.action_body) else { continue }; - if body.kind != AssocSurfaceActionKind::Swept { continue; } let Some(AssociativeData::Dependency(written)) = object(body.surface_body.dependency) else { continue }; + // The entities a named path parameter reads (an edge of a region + // reads through a geometry dependency). + let read = |name: &str| -> Vec { + action.owned_parameters.iter().filter_map(|parameter| { + let Some(AssociativeData::PathActionParam(path)) = object(*parameter) else { return None }; + if path.compound.action_param.name != name { return None; } + let Some(AssociativeData::EdgeActionParam(edge)) = object(*path.compound.parameters.first()?) else { return None }; + match object(edge.single_dependency.dependency) { + Some(AssociativeData::Dependency(dependency)) => Some(dependency.dependent_on), + Some(AssociativeData::GeomDependency(dependency)) => Some(dependency.dependency.dependent_on), + _ => None, + } + }).collect() + }; + let real = |name: &str| action.values.iter().find(|value| value.name == name) + .and_then(|value| value.variables.first()) + .and_then(|variable| match variable.value.value { + codec::objects::AssocEvalValue::Real(value) => Some(value), + _ => None, + }); + match body.kind { + AssocSurfaceActionKind::Extruded => { + if let (Some(profile), Some(height), Some(taper)) = + (read("ExtrudeProfile").first().copied(), real("ExtrusionHeight"), real("ExtrusionTaperAngle")) + { + others.push((written.dependent_on, (body.kind, vec![profile], None, height, taper))); + } + continue; + } + AssocSurfaceActionKind::Revolved => { + if let (Some(profile), Some(angle)) = (read("RevolveProfile").first().copied(), real("RevolveAngle")) { + others.push((written.dependent_on, (body.kind, vec![profile], read("RevolvePath").first().copied(), angle, 0.0))); + } + continue; + } + AssocSurfaceActionKind::Lofted => { + let sections = read("CrossSection"); + if sections.len() >= 2 { + others.push((written.dependent_on, (body.kind, sections, None, 0.0, 0.0))); + } + continue; + } + AssocSurfaceActionKind::Swept => {} + _ => continue, + } let mut expressions: [Option; 2] = [None, None]; for value in &action.values { let slot = match value.name.as_str() { "ScaleFactor" => 0, "TwistAngle" => 1, _ => continue }; @@ -1210,18 +1347,7 @@ impl Scene { links.push((written.dependent_on, expressions)); } // The profile and path the action reads. - let source = |name: &str| { - action.owned_parameters.iter().find_map(|parameter| { - let Some(AssociativeData::PathActionParam(path)) = object(*parameter) else { return None }; - if path.compound.action_param.name != name { return None; } - let Some(AssociativeData::EdgeActionParam(edge)) = object(*path.compound.parameters.first()?) else { return None }; - match object(edge.single_dependency.dependency) { - Some(AssociativeData::Dependency(dependency)) => Some(dependency.dependent_on), - _ => None, - } - }) - }; - if let (Some(profile), Some(path)) = (source("SweepProfile"), source("SweepPath")) { + if let (Some(profile), Some(path)) = (read("SweepProfile").first().copied(), read("SweepPath").first().copied()) { sources.push((written.dependent_on, profile, path)); } } @@ -1241,6 +1367,44 @@ impl Scene { if crate::scene::model::sweep_model::sweep_expressions(entity).is_some() { continue; } crate::scene::model::sweep_model::link_sweep_expressions(&mut self.document, handle, &expressions); } + // Associative extruded, revolved and lofted surfaces follow their + // sources here too. + use crate::scene::model::surface_sources::{link_surface_sources, surface_sources, SurfaceSources}; + for (surface, (kind, handles, axis, amount, taper)) in others { + let Some(entity @ EntityType::Surface(value)) = self.document.get_entity(surface) else { continue }; + if surface_sources(entity).is_some() { continue; } + let linked = match (kind, &value.surface_data) { + (AssocSurfaceActionKind::Extruded, codec::entities::SurfaceData::Extruded { sweep_vector, .. }) => { + let profile = handles[0]; + let along = glam::DVec3::new(sweep_vector.x, sweep_vector.y, sweep_vector.z); + // A direction other than the profile's normal is kept. + let normal = self.document.get_entity(profile) + .and_then(|entity| crate::scene::model::sweep_model::extrusion_profile_of(entity)) + .and_then(|(shape, _)| shape.plane.normal()) + .map(glam::DVec3::from_array); + let direction = match normal { + Some(normal) if (normal * amount - along).length() <= 1e-9 * amount.abs().max(1.0) => None, + _ if along.length() > 1e-12 => Some(along), + _ => None, + }; + SurfaceSources::Extrude { profile, height: amount, direction, taper } + } + (AssocSurfaceActionKind::Revolved, codec::entities::SurfaceData::Revolved { axis_point, axis_vector, start_angle, .. }) => { + let start = glam::DVec3::new(axis_point.x, axis_point.y, axis_point.z); + SurfaceSources::Revolve { + profile: handles[0], + axis, + start, + end: start + glam::DVec3::new(axis_vector.x, axis_vector.y, axis_vector.z), + angle: amount, + start_angle: *start_angle, + } + } + (AssocSurfaceActionKind::Lofted, _) => SurfaceSources::Loft { sections: handles }, + _ => continue, + }; + link_surface_sources(&mut self.document, surface, &linked); + } } pub fn rebuild_solid_history( From 37a9e5775d6c92b3bf1bcbf207dfa7ab8847993c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:41:13 +0300 Subject: [PATCH 2/9] Read region sources of associative surfaces by edge A swept, extruded, revolved or lofted surface made from a region now reads the region through one geometry dependency per edge, each naming its edge by a persistent subentity id recorded in the drawing's subentity manager, so the surface follows the region. A lofted surface keeps one continuity and bulge value per cross section. Co-Authored-By: Claude Opus 5.5 --- src/scene/dwg_native_constraints.rs | 311 ++++++++++++++++++------- src/scene/model/sweep_command_model.rs | 115 +++++++-- src/scene/model/sweep_model.rs | 2 +- 3 files changed, 329 insertions(+), 99 deletions(-) diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index d3074f691..87376b4b7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2648,8 +2648,8 @@ fn pending_swept_surfaces(document: &CadDocument) -> Vec<(Handle, Handle, Handle let (profile, path) = sweep_model::sweep_sources(entity)?; let surface = entity.common().handle; (!carried.contains(&surface) - && document.get_entity(profile).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some()) - && document.get_entity(path).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some())) + && document.get_entity(profile).is_some_and(|e| sweep_model::assoc_source_supported(e)) + && document.get_entity(path).is_some_and(|e| sweep_model::assoc_source_supported(e))) .then_some((surface, profile, path)) }) .collect::>(); @@ -2676,7 +2676,7 @@ fn pending_source_surfaces( let surface = entity.common().handle; (!carried.contains(&surface) && sources.handles().iter().all(|handle| { - document.get_entity(*handle).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some()) + document.get_entity(*handle).is_some_and(|e| sweep_model::assoc_source_supported(e)) })) .then_some((surface, sources)) }) @@ -2690,6 +2690,191 @@ fn has_pending_surfaces(document: &CadDocument) -> bool { !pending_swept_surfaces(document).is_empty() || !pending_source_surfaces(document).is_empty() } +/// Named-objects dictionary key of the drawing's persistent subentity manager. +const SUBENT_MANAGER_KEY: &str = "ACAD_ASSOCPERSSUBENTMANAGER"; + +/// A read dependency of an action on `dependent_on`. +fn read_dependency(dependent_on: Handle, id: i32) -> AssocDependency { + AssocDependency { + class_version: 2, + status: 0, + is_read_dependency: true, + is_write_dependency: false, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + order: 0, + dependent_on, + name: None, + read_dependency: Handle::NULL, + node: Handle::NULL, + dependency_body: Handle::NULL, + dependency_body_id: id, + } +} + +/// An edge action parameter reading `curve` through dependency `read`. +fn edge_action_param(read: Handle, (action_type, subcurve_kind, curve): crate::scene::model::sweep_model::AssocCurve) -> AssociativeData { + use codec::objects::{AssocActionParam, AssocEdgeActionParam, AssocSingleDependencyActionParam}; + AssociativeData::EdgeActionParam(AssocEdgeActionParam { + single_dependency: AssocSingleDependencyActionParam { + action_param: AssocActionParam { is_r2013: 1, version: 0, name: String::new() }, + dependency_class_version: 0, + dependency: read, + class_version: 0, + }, + parameter: Handle::NULL, + has_action: true, + action_type, + subcurve_kind, + curve, + }) +} + +/// The subentity manager's record of a region's edges: one entry per edge +/// id from `first_id`, in the order the region's dependencies read them, +/// each naming the edge (and, between edges, its two vertices) by the +/// region's face and edge numbers. `loops` holds each loop's edge count. +fn region_subent_entries(loops: &[usize], step: i32, first_id: i32) -> Vec { + const BODY: i32 = 1_000_000_000; + const FACE: i32 = 1001; + let mut values = Vec::new(); + let (mut id, mut offset) = (first_id, 0usize); + for &count in loops { + let edge = |index: usize| 51001 + 100_000 * (offset + index % count) as i32; + let vertex = |a: i32, b: i32| [1, 3, 1, BODY, FACE, 1, BODY, a, 1, BODY, b, 0, 0, 0, 900, 0, 900, 0, 0]; + for read in 0..count { + if count == 1 { + values.extend([4, 0, 5, id, step, step, 0, 0, 24, 0, 0, 1, BODY, edge(0), 0, 0, 1, 1, BODY, FACE]); + values.extend([0; 14]); + } else { + // The dependencies start at the loop's second edge. + let index = read + 1; + values.extend([4, 0, 11, id, step, step, 0, 2, 2, 2, 0, 2, 2, 2, 54, 0, 0, 1, BODY, edge(index)]); + values.extend([0, 0, 1, 1, BODY, FACE, 0, 0, 0, 0]); + values.extend(vertex(edge(index), edge(index + count - 1))); + values.extend([0, 0]); + values.extend(vertex(edge(index + 1), edge(index))); + } + id += 1; + } + offset += count; + } + values +} + +/// Records a region read by an action in the drawing's persistent +/// subentity manager (made when the drawing has none): a step for the +/// action and one for the region, then one id per edge. The manager keeps +/// the last id given, its edge entries and the list of every id given. +/// Returns the edges' ids, or `None` when the drawing's manager has a form +/// this cannot extend. +fn register_region_subents(document: &mut CadDocument, loops: &[usize]) -> Option> { + use codec::objects::AssocPersSubentManager; + let root = document.header.named_objects_dict_handle; + let existing = match document.objects.get(&root) { + Some(ObjectType::Dictionary(dictionary)) => dictionary.get(SUBENT_MANAGER_KEY), + _ => return None, + }; + let current = existing.and_then(|handle| match document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::PersSubentManager(manager), .. })) => { + Some((handle, manager.values.clone())) + } + _ => None, + }); + let (handle, last, count, mut entries) = match current { + Some((handle, values)) => { + let last = *values.first()?; + if values.len() == 4 && values[1..].iter().all(|value| *value == 0) { + (handle, last, 0, Vec::new()) + } else { + let tail = usize::try_from(last).ok()? + 3; + if values.len() < tail + 2 || values[values.len() - tail] != last { + return None; + } + (handle, last, values[1], values[2..values.len() - tail].to_vec()) + } + } + None => { + let handle = document.allocate_handle(); + set_dictionary_entry(document, root, SUBENT_MANAGER_KEY, handle); + (handle, 0, 0, Vec::new()) + } + }; + let edges = loops.iter().sum::() as i32; + let first = last + 3; + entries.extend(region_subent_entries(loops, last + 2, first)); + let last = last + 2 + edges; + let mut values = vec![last, count + edges]; + values.extend(entries); + values.push(last); + values.extend(1..=last); + values.extend([0, 0]); + document.objects.insert(handle, ObjectType::Associative(AssociativeObject { + handle, + owner: root, + reactors: vec![root], + dxf_name: "ACDBASSOCPERSSUBENTMANAGER".to_string(), + cpp_class_name: "AcDbAssocPersSubentManager".to_string(), + data: AssociativeData::PersSubentManager(AssocPersSubentManager { + class_version: 2, + markers: [3, 0, 2], + values, + final_flag: false, + }), + ..Default::default() + })); + Some((first..first + edges).collect()) +} + +/// How an action reads one source through `parameter`: one read dependency +/// on a curve; on a region one geometry dependency per edge, naming the +/// edge by its persistent subentity id and chained on the region. Adds the +/// dependencies (owned by `action`) and edge parameters to `objects` with +/// ids from `first_id`; returns (edge parameter, dependency) pairs. +fn source_reads( + document: &mut CadDocument, + action: Handle, + parameter: Handle, + source: Handle, + first_id: i32, + objects: &mut Vec<(Handle, Handle, &'static str, &'static str, AssociativeData)>, +) -> Option> { + use crate::scene::model::sweep_model; + let entity = document.get_entity(source)?; + let Some(loops) = sweep_model::region_edge_curves(entity) else { + let curve = sweep_model::assoc_edge_curve(entity)?; + let (edge, read) = (document.allocate_handle(), document.allocate_handle()); + objects.push((edge, parameter, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_action_param(read, curve))); + objects.push((read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", + AssociativeData::Dependency(read_dependency(source, first_id)))); + return Some(vec![(edge, read)]); + }; + let sizes = loops.iter().map(Vec::len).collect::>(); + let ids = register_region_subents(document, &sizes)?; + let curves = loops.into_iter().flatten().collect::>(); + let pairs = curves.iter().map(|_| (document.allocate_handle(), document.allocate_handle())).collect::>(); + for (index, (curve, (edge, read))) in curves.into_iter().zip(pairs.iter().copied()).enumerate() { + let mut dependency = read_dependency(source, first_id + index as i32); + dependency.read_dependency = index.checked_sub(1).map_or(Handle::NULL, |previous| pairs[previous].1); + dependency.node = pairs.get(index + 1).map_or(Handle::NULL, |next| next.1); + objects.push((edge, parameter, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_action_param(read, curve))); + objects.push((read, action, "ACDBASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency", + AssociativeData::GeomDependency(AssocGeomDependency { + dependency, + class_version: 0, + enabled: true, + persistent_subent: AssocPersistentSubentId { + class_name: "AcDbAssocAsmBasedEntityPersSubentId".to_string(), + dependent_on_compound_object: false, + class_code: 5, + values: vec![0, 2, ids[index]], + leading_flag: false, + }, + }))); + } + Some(pairs) +} + /// The reference's associative network for each pending extruded, revolved /// or lofted surface: an action with the matching surface action body that /// writes the surface and reads its sources through path parameters (an @@ -2703,7 +2888,6 @@ fn materialize_source_surface_actions( first_index: i32, ) -> Vec { use crate::scene::model::surface_sources::SurfaceSources; - use crate::scene::model::sweep_model; use codec::objects::*; let mut added = Vec::new(); for (surface, sources) in pending.iter() { @@ -2748,9 +2932,11 @@ fn materialize_source_surface_actions( AssocSurfaceActionKind::Lofted, "AcDbAssocLoftedSurfaceActionBody", "ACDBASSOCLOFTEDSURFACEACTIONBODY", + // One value per cross section: the start and end + // settings at the ends, the defaults between them. vec![ - ("Continuity", 0, vec![short(continuity.0), short(continuity.1)], false), - ("Bulge", 0, vec![real(bulge.0), real(bulge.1)], false), + ("Continuity", 0, (0..sections.len()).map(|index| short(if index == 0 { continuity.0 } else if index + 1 == sections.len() { continuity.1 } else { 1 })).collect(), false), + ("Bulge", 0, (0..sections.len()).map(|index| real(if index == 0 { bulge.0 } else if index + 1 == sections.len() { bulge.1 } else { 0.5 })).collect(), false), ], sections.iter().map(|section| ("CrossSection", Some(*section))) .chain(std::iter::once(("PathCurve", None))) @@ -2758,61 +2944,29 @@ fn materialize_source_surface_actions( ) } }; - let curves = params - .iter() - .map(|(_, source)| match source { - Some(source) => document.get_entity(*source).and_then(sweep_model::assoc_edge_curve).map(Some), - None => Some(None), - }) - .collect::>>(); - let Some(curves) = curves else { continue }; let document = &mut *allocator.document; let (action, body, written) = (document.allocate_handle(), document.allocate_handle(), document.allocate_handle()); - let dependency = |dependent_on: Handle, read: bool, id: i32, order: i32| AssocDependency { - class_version: 2, - status: 0, - is_read_dependency: read, - is_write_dependency: !read, - is_attached_to_object: true, - is_delegating_to_owning_action: true, - order, - dependent_on, - name: None, - read_dependency: Handle::NULL, - node: Handle::NULL, - dependency_body: Handle::NULL, - dependency_body_id: id, - }; - let mut objects: Vec<(Handle, Handle, &str, &str, AssociativeData)> = Vec::new(); + let mut objects: Vec<(Handle, Handle, &'static str, &'static str, AssociativeData)> = Vec::new(); let mut reactors: Vec<(Handle, Handle)> = vec![(surface, written)]; let mut dependencies = vec![written]; let mut owned = Vec::new(); - objects.push((written, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(surface, false, 1, i32::MIN)))); - for ((name, source), curve) in params.iter().zip(curves) { - let document = &mut *allocator.document; + let mut writes = read_dependency(surface, 1); + (writes.is_read_dependency, writes.is_write_dependency, writes.order) = (false, true, i32::MIN); + objects.push((written, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(writes))); + let mut complete = true; + for (name, source) in params.iter() { let parameter = document.allocate_handle(); let mut children = Vec::new(); - if let (Some(source), Some((action_type, subcurve_kind, curve))) = (source, curve) { - let (edge, read) = (document.allocate_handle(), document.allocate_handle()); - objects.push((edge, parameter, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", - AssociativeData::EdgeActionParam(AssocEdgeActionParam { - single_dependency: AssocSingleDependencyActionParam { - action_param: AssocActionParam { is_r2013: 1, version: 0, name: String::new() }, - dependency_class_version: 0, - dependency: read, - class_version: 0, - }, - parameter: Handle::NULL, - has_action: true, - action_type, - subcurve_kind, - curve, - }))); - objects.push((read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", - AssociativeData::Dependency(dependency(*source, true, dependencies.len() as i32 + 1, 0)))); - reactors.push((*source, read)); - dependencies.push(read); - children.push(edge); + if let Some(source) = source { + let Some(reads) = source_reads(document, action, parameter, *source, dependencies.len() as i32 + 1, &mut objects) else { + complete = false; + break; + }; + for (edge, read) in reads { + reactors.push((*source, read)); + dependencies.push(read); + children.push(edge); + } } objects.push((parameter, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", AssociativeData::PathActionParam(AssocPathActionParam { @@ -2827,6 +2981,9 @@ fn materialize_source_surface_actions( }))); owned.push(parameter); } + if !complete { + continue; + } objects.push((action, network, "ACDBASSOCACTION", "AcDbAssocAction", AssociativeData::Action(AssocAction { class_version: 2, geometry_status: 0, @@ -2915,10 +3072,6 @@ fn materialize_swept_surface_actions( let codec::entities::SurfaceData::Swept { options, .. } = &surface_value.surface_data else { continue }; let (rotation, scale, twist) = (options.align_angle, options.scale_factor, options.twist_angle); let expressions = sweep_model::sweep_expressions(surface_entity).unwrap_or([None, None]); - let (Some(profile_curve), Some(path_curve)) = ( - document.get_entity(profile).and_then(sweep_model::assoc_edge_curve), - document.get_entity(path).and_then(sweep_model::assoc_edge_curve), - ) else { continue }; let evaluate = |expression: &str| { let mut table = parameters.clone(); table.set("sweepExpression", expression).ok()?; @@ -2926,8 +3079,7 @@ fn materialize_swept_surface_actions( }; let document = &mut *allocator.document; let mut next = || document.allocate_handle(); - let (action, body, written, profile_param, profile_edge, profile_read, path_param, path_edge, path_read) = - (next(), next(), next(), next(), next(), next(), next(), next(), next()); + let (action, body, written, profile_param, path_param) = (next(), next(), next(), next(), next()); let dependency = |dependent_on: Handle, read: bool, id: i32, order: i32| AssocDependency { class_version: 2, status: 0, @@ -2943,9 +3095,15 @@ fn materialize_swept_surface_actions( dependency_body: Handle::NULL, dependency_body_id: id, }; - let mut objects: Vec<(Handle, Handle, &str, &str, AssociativeData)> = Vec::new(); - let mut reactors: Vec<(Handle, Handle)> = vec![(surface, written), (profile, profile_read), (path, path_read)]; - let mut dependencies = vec![written, profile_read, path_read]; + let mut objects: Vec<(Handle, Handle, &'static str, &'static str, AssociativeData)> = Vec::new(); + // The profile (a region by its edges) and the path, read in that order. + let Some(profile_reads) = source_reads(document, action, profile_param, profile, 2, &mut objects) else { continue }; + let Some(path_reads) = source_reads(document, action, path_param, path, profile_reads.len() as i32 + 2, &mut objects) else { continue }; + let mut reactors: Vec<(Handle, Handle)> = vec![(surface, written)]; + reactors.extend(profile_reads.iter().map(|(_, read)| (profile, *read))); + reactors.extend(path_reads.iter().map(|(_, read)| (path, *read))); + let mut dependencies = vec![written]; + dependencies.extend(profile_reads.iter().chain(&path_reads).map(|(_, read)| *read)); let mut value_handles = [Handle::NULL, Handle::NULL]; let mut network_actions = vec![action]; // Scale (slot 0) and twist (slot 1) expressions. @@ -3029,35 +3187,20 @@ fn materialize_swept_surface_actions( ..Default::default() }))); objects.push((written, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(surface, false, 1, i32::MIN)))); - let edge_param = |read: Handle, (kind, subcurve, curve): (i32, AssocSubcurveKind, Vec)| AssociativeData::EdgeActionParam(AssocEdgeActionParam { - single_dependency: AssocSingleDependencyActionParam { - action_param: AssocActionParam { is_r2013: 1, version: 0, name: String::new() }, - dependency_class_version: 0, - dependency: read, - class_version: 0, - }, - parameter: Handle::NULL, - has_action: true, - action_type: kind, - subcurve_kind: subcurve, - curve, - }); - let path_parameter = |name: &str, child: Handle| AssociativeData::PathActionParam(AssocPathActionParam { + let path_parameter = |name: &str, children: Vec| AssociativeData::PathActionParam(AssocPathActionParam { compound: AssocCompoundActionParam { action_param: AssocActionParam { is_r2013: 1, version: 0, name: name.to_string() }, class_version: 0, status: 0, - parameters: vec![child], + parameters: children, child_parameter: None, }, version: 0, }); - objects.push((profile_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", path_parameter("SweepProfile", profile_edge))); - objects.push((profile_edge, profile_param, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_param(profile_read, profile_curve))); - objects.push((profile_read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(profile, true, 2, 0)))); - objects.push((path_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", path_parameter("SweepPath", path_edge))); - objects.push((path_edge, path_param, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_param(path_read, path_curve))); - objects.push((path_read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(path, true, 3, 0)))); + objects.push((profile_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", + path_parameter("SweepProfile", profile_reads.iter().map(|(edge, _)| *edge).collect()))); + objects.push((path_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", + path_parameter("SweepPath", path_reads.iter().map(|(edge, _)| *edge).collect()))); for (handle, owner, dxf, class, data) in objects { allocator.insert_associative_at(handle, owner, dxf, class, data); } diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index 6847c980c..fd21e3e66 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -452,7 +452,7 @@ pub fn resweep_from_sources( /// parameter: line segment (23: start, vector to the end), arc (11: /// centre, normal, reference axis, radius, start and end angle, 0) or a /// composite of those (47: count, then each part's type and values). -pub fn assoc_edge_curve(entity: &EntityType) -> Option<(i32, codec::objects::AssocSubcurveKind, Vec)> { +pub fn assoc_edge_curve(entity: &EntityType) -> Option { use codec::objects::{AssocCurveValue as V, AssocSubcurveKind as K}; use codec::types::Vector3 as P; let point = |p: glam::DVec3| V::Point(P::new(p.x, p.y, p.z)); @@ -564,25 +564,112 @@ pub fn assoc_edge_curve(entity: &EntityType) -> Option<(i32, codec::objects::Ass } let points = curve.control_points(); if points.len() < 2 || knots.is_empty() { return None; } - let rational = curve.weights().iter().any(|weight| (weight - 1.0).abs() > 1e-12); - let header = |values: &mut Vec, count: usize| { - values.extend([V::Int(count as i32), V::Int(if count == 0 { 0 } else { count.max(8) } as i32), V::Int(8)]); - }; let tolerance = if spline.knot_tolerance > 0.0 { spline.knot_tolerance } else { 1e-9 }; - let mut values = vec![V::Bool(rational), V::Bool(false), V::Int(curve.degree() as i32), V::Real(tolerance)]; - header(&mut values, knots.len()); - values.extend(knots.iter().map(|knot| V::Real(*knot))); - let weights = if rational { curve.weights() } else { &[] }; - header(&mut values, weights.len()); - values.extend(weights.iter().map(|weight| V::Real(*weight))); - header(&mut values, points.len()); - values.extend(points.iter().map(|p| V::Point(P::new(p[0], p[1], p[2])))); - Some((42, K::Nurb3d, values)) + let points = points.iter().map(|p| glam::DVec3::from_array(*p)).collect::>(); + Some(nurbs_curve(curve.degree(), tolerance, &knots, curve.weights(), &points)) } _ => None, } } +/// A NURBS curve's edge parameter values (42): rational flag, a second flag +/// (0), degree, knot tolerance, then knots, weights (empty unless rational) +/// and control points, each as its length, the length reserved (at least +/// 8) and the growth step 8, followed by the items. +fn nurbs_curve(degree: usize, tolerance: f64, knots: &[f64], weights: &[f64], points: &[glam::DVec3]) -> AssocCurve { + use codec::objects::{AssocCurveValue as V, AssocSubcurveKind as K}; + let rational = weights.iter().any(|weight| (weight - 1.0).abs() > 1e-12); + let header = |values: &mut Vec, count: usize| { + values.extend([V::Int(count as i32), V::Int(if count == 0 { 0 } else { count.max(8) } as i32), V::Int(8)]); + }; + let mut values = vec![V::Bool(rational), V::Bool(false), V::Int(degree as i32), V::Real(tolerance)]; + header(&mut values, knots.len()); + values.extend(knots.iter().map(|knot| V::Real(*knot))); + let weights = if rational { weights } else { &[] }; + header(&mut values, weights.len()); + values.extend(weights.iter().map(|weight| V::Real(*weight))); + header(&mut values, points.len()); + values.extend(points.iter().map(|p| V::Point(codec::types::Vector3::new(p.x, p.y, p.z)))); + (42, K::Nurb3d, values) +} + +/// An edge parameter's curve: kind, subcurve kind and values. +pub type AssocCurve = (i32, codec::objects::AssocSubcurveKind, Vec); + +/// A region's edges as an associative action reads them, loop by loop, each +/// loop from its second edge on as the reference lists them: lines (23), +/// circles and arcs (11) measured from the arbitrary axis of the region's +/// normal, elliptical arcs (17) and NURBS curves (42). +pub fn region_edge_curves(entity: &EntityType) -> Option>> { + use codec::objects::{AssocCurveValue as V, AssocSubcurveKind as K}; + use kernel::geom2d::Curve; + let EntityType::Region(_) = entity else { return None }; + let Some((plane, loops, true)) = super::presspull_model::profile_geometry(entity) else { return None }; + let at = |uv: [f64; 2]| glam::DVec3::from_array(plane.point_at(uv)); + let normal = glam::DVec3::from_array(plane.x_axis).cross(glam::DVec3::from_array(plane.y_axis)).try_normalize()?; + let axis = arbitrary_x(normal); + let across = normal.cross(axis); + let point = |p: glam::DVec3| V::Point(codec::types::Vector3::new(p.x, p.y, p.z)); + let arc = |centre: glam::DVec3, radius: f64, start: f64, end: f64| (11, K::Arc, vec![ + point(centre), point(normal), point(axis), V::Real(radius), V::Real(start), V::Real(end), V::Real(0.0), + ]); + let curve = |piece: &Curve| -> Option { + match piece { + Curve::Line(line) => { + let (a, b) = (at(line.start), at(line.end)); + Some((23, K::LineSegment3d, vec![point(a), point(b - a)])) + } + Curve::Circle(circle) => Some(arc(at(circle.centre), circle.radius, 0.0, std::f64::consts::TAU)), + Curve::Arc(value) => { + let centre = at(value.centre); + let offset = at([ + value.centre[0] + value.start_angle.cos(), + value.centre[1] + value.start_angle.sin(), + ]) - centre; + let start = offset.dot(across).atan2(offset.dot(axis)); + Some(arc(centre, value.radius, start, start + value.sweep())) + } + Curve::Ellipse(value) => { + let ellipse = value.ellipse; + let centre = at(ellipse.centre); + let tip = [ellipse.centre[0] + ellipse.major_axis[0], ellipse.centre[1] + ellipse.major_axis[1]]; + let major = (at(tip) - centre).try_normalize()?; + let end = if value.end_parameter > value.start_parameter { + value.end_parameter + } else { + value.end_parameter + std::f64::consts::TAU + }; + Some((17, K::Ellipse, vec![ + point(centre), point(major), point(normal.cross(major)), + V::Real(ellipse.major_radius), V::Real(ellipse.minor_radius), + V::Real(value.start_parameter), V::Real(end), V::Real(0.0), + ])) + } + Curve::Nurbs(value) => { + let points = value.control_points().iter().map(|p| at(*p)).collect::>(); + Some(nurbs_curve(value.degree(), 1e-9, value.knots(), value.weights(), &points)) + } + _ => None, + } + }; + loops + .iter() + .map(|pieces| { + let mut curves = pieces.iter().map(curve).collect::>>()?; + if curves.len() > 1 { + curves.rotate_left(1); + } + Some(curves) + }) + .collect() +} + +/// Whether an associative action can read this source: a region's edges +/// or the entity's own curve. +pub fn assoc_source_supported(entity: &EntityType) -> bool { + region_edge_curves(entity).is_some() || assoc_edge_curve(entity).is_some() +} + /// The arbitrary-axis X direction of an entity normal. fn arbitrary_x(normal: glam::DVec3) -> glam::DVec3 { let world = if normal.x.abs() < 1.0 / 64.0 && normal.y.abs() < 1.0 / 64.0 { glam::DVec3::Y } else { glam::DVec3::Z }; diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index fb5f12072..ac1300ad2 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -13,7 +13,7 @@ use codec::EntityType; use crate::entities::curve::entity_curve; pub use super::sweep_command_model::{ - is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, link_sweep_sources, sweep_sources, SWEEP_SOURCES_APP, resweep_from_sources, surface_associativity, set_surface_associativity, assoc_edge_curve, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, + is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, link_sweep_sources, sweep_sources, SWEEP_SOURCES_APP, resweep_from_sources, surface_associativity, set_surface_associativity, assoc_edge_curve, assoc_source_supported, region_edge_curves, AssocCurve, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, }; /// A drawn profile, as the kernel wants it: the plane it lies in and the From 2192baba0240e1f54c9951c530f868314399d427 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:03:44 +0300 Subject: [PATCH 3/9] Extrude a region's exact boundary loop as a surface A region of several edges kept its display wires one per edge, so the surface extrusion found no polygon and refused it. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/sweep_model.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index ac1300ad2..1fadb9183 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -291,8 +291,11 @@ pub fn extruded_surface( direction: [f64; 3], taper_angle: f64, ) -> Option { - if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { - return None; + // A region's surface is its one boundary loop, read from its exact edges. + if let EntityType::Region(_) = entity { + let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; + let [boundary] = loops.as_slice() else { return None }; + return brep::extrude_surface_tapered(plane, boundary, direction, taper_angle); } let (profile, _) = extrusion_profile_of(entity)?; brep::extrude_surface_tapered( From 8fc4f42a4d1b3c8b1a98cdf9b449bd357df8b942 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:17:57 +0300 Subject: [PATCH 4/9] Read a one-loop region's profile from its exact edges A region keeps one display wire per edge, so a region of several edges gave no polygon and could not be extruded (solid or surface), and a circle region was extruded from its display polygon. Profiles of a region with one boundary loop now come from the region's modeler edges. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/sweep_model.rs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 1fadb9183..5e41f3f72 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -32,6 +32,9 @@ pub struct Profile { /// is handed over as the arcs it is made of rather than as one curve with /// nowhere for the sweep to start. pub fn profile_of(entity: &EntityType) -> Option { + if let Some(profile) = region_loop(entity) { + return (profile.pieces.len() >= 3).then_some(profile); + } let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; if !planar.curve.is_closed() { return None; @@ -46,6 +49,9 @@ pub fn profile_of(entity: &EntityType) -> Option { /// An entity-level planar curve suitable for either a solid profile or an /// open surface profile. pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> { + if let Some(profile) = region_loop(entity) { + return Some((profile, true)); + } let planar = entity_curve(entity).or_else(|| planar_polygon_entity(entity))?; let closed = planar.curve.is_closed(); let pieces = profile_pieces(&planar.curve, closed)?; @@ -58,6 +64,22 @@ pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> { )) } +/// A region of one boundary loop as that loop, read from the region's exact +/// edges (its display wires hold one wire per edge), a lone closed edge split +/// as a closed curve is. +fn region_loop(entity: &EntityType) -> Option { + let EntityType::Region(_) = entity else { return None }; + let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; + let [boundary] = loops.as_slice() else { return None }; + let single = boundary.len() == 1; + let pieces = boundary + .iter() + .map(|piece| profile_pieces(piece, single)) + .collect::>>()? + .concat(); + Some(Profile { plane, pieces }) +} + fn profile_pieces(curve: &Curve, closed: bool) -> Option> { let pieces = match curve { Curve::Polyline(_) => curve.segments(), @@ -291,11 +313,8 @@ pub fn extruded_surface( direction: [f64; 3], taper_angle: f64, ) -> Option { - // A region's surface is its one boundary loop, read from its exact edges. - if let EntityType::Region(_) = entity { - let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; - let [boundary] = loops.as_slice() else { return None }; - return brep::extrude_surface_tapered(plane, boundary, direction, taper_angle); + if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + return None; } let (profile, _) = extrusion_profile_of(entity)?; brep::extrude_surface_tapered( From 65910d1cad0d5f4a78b8b4672e54b15f1416bd76 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:32:35 +0300 Subject: [PATCH 5/9] Count a region's loops from its modeler edges The surface extrusion, the extrusion along a path, revolve history and embedded profiles refused a region whenever it had more than one display wire, which a region of several edges always has. They now refuse only a region with holes, and a region's loops for a solid extrusion come from its modeler edges. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/sweep_model.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 5e41f3f72..16b66c16a 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -68,7 +68,10 @@ pub fn extrusion_profile_of(entity: &EntityType) -> Option<(Profile, bool)> { /// edges (its display wires hold one wire per edge), a lone closed edge split /// as a closed curve is. fn region_loop(entity: &EntityType) -> Option { - let EntityType::Region(_) = entity else { return None }; + let EntityType::Region(region) = entity else { return None }; + if !region.acis_data.has_data() { + return None; + } let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; let [boundary] = loops.as_slice() else { return None }; let single = boundary.len() == 1; @@ -80,6 +83,11 @@ fn region_loop(entity: &EntityType) -> Option { Some(Profile { plane, pieces }) } +/// Whether a region has more than one boundary loop. +fn region_with_holes(entity: &EntityType) -> bool { + matches!(entity, EntityType::Region(_)) && region_profiles(entity).is_some_and(|(_, loops)| loops.len() > 1) +} + fn profile_pieces(curve: &Curve, closed: bool) -> Option> { let pieces = match curve { Curve::Polyline(_) => curve.segments(), @@ -182,10 +190,16 @@ fn planar_polygon_entity(entity: &EntityType) -> Option { )) } +/// A region's boundary loops: from its modeler edges when it has them (its +/// display wires then hold one wire per edge), else from its wires. fn region_profiles(entity: &EntityType) -> Option<(Plane, Vec>)> { let EntityType::Region(region) = entity else { return None; }; + if region.acis_data.has_data() { + let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; + return (!loops.is_empty()).then_some((plane, loops)); + } let plane = planar_polygon_entity(entity)?.plane; let mut profiles = Vec::with_capacity(region.wires.len()); for wire in ®ion.wires { @@ -313,7 +327,7 @@ pub fn extruded_surface( direction: [f64; 3], taper_angle: f64, ) -> Option { - if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + if region_with_holes(entity) { return None; } let (profile, _) = extrusion_profile_of(entity)?; @@ -330,7 +344,7 @@ pub fn extruded_along_path( path: &EntityType, taper_angle: f64, ) -> Option { - if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + if region_with_holes(entity) { if taper_angle.abs() > 1e-12 { return None; } @@ -493,7 +507,7 @@ pub fn revolve_history( if !angle.is_finite() || angle.abs() <= 1e-12 || !start_angle.is_finite() - || matches!(entity, EntityType::Region(region) if region.wires.len() > 1) + || region_with_holes(entity) { return None; } @@ -649,7 +663,7 @@ pub fn embedded_path(entity: &EntityType) -> Option { } fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { - if matches!(entity, EntityType::Region(region) if region.wires.len() > 1) { + if region_with_holes(entity) { return None; } if !matches!(entity, EntityType::Polyline3D(_)) { From c227de26c5daf4458f9389879af9f127c49d9c50 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:54:52 +0300 Subject: [PATCH 6/9] Record a region profile as its boundary curve and step A swept, extruded or revolved surface made from a region records the region as the curve round it, as the reference does: a lone circle or whole ellipse as itself, a loop of lines and arcs as a closed polyline in the region's plane. A region recorded as a region made the reference refuse to open the drawing (modeling error 58000). A region read by an associative action carries the step its edge names belong to (ACAD_STEPID), so the reference finds the edges again and the surface follows the region. Co-Authored-By: Claude Opus 5.5 --- src/scene/dwg_native_constraints.rs | 5 ++ src/scene/model/sweep_command_model.rs | 5 +- src/scene/model/sweep_model.rs | 88 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 87376b4b7..68f65065c 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2851,6 +2851,11 @@ fn source_reads( }; let sizes = loops.iter().map(Vec::len).collect::>(); let ids = register_region_subents(document, &sizes)?; + // The region carries the step its edge names belong to; without it (and + // without named edges in its modeler data) the reference cannot find + // the edges again. + crate::scene::view::dispatch::set_entity_xdata(document, source, "ACAD_STEPID", + Some(vec![codec::xdata::XDataValue::Integer32(ids.first()? - 1)])); let curves = loops.into_iter().flatten().collect::>(); let pairs = curves.iter().map(|_| (document.allocate_handle(), document.allocate_handle())).collect::>(); for (index, (curve, (edge, read))) in curves.into_iter().zip(pairs.iter().copied()).enumerate() { diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index fd21e3e66..d6f3d0ba1 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -12,7 +12,10 @@ use super::sweep_model::{embedded_path, embedded_revolve_profile}; /// A 3D polyline profile or path as the reference records it: a wire body. fn embedded_sweep_profile(entity: &EntityType) -> Option<(codec::entities::EmbeddedEntity, [f64; 16])> { match entity { - EntityType::Region(region) => Some((codec::entities::EmbeddedEntity::Region(region.clone()), glam::DMat4::IDENTITY.to_cols_array())), + // A region as the curve round it, as the reference records it. + EntityType::Region(region) => super::sweep_model::region_boundary_entity(entity).or_else(|| { + Some((codec::entities::EmbeddedEntity::Region(region.clone()), glam::DMat4::IDENTITY.to_cols_array())) + }), EntityType::Polyline3D(value) => Some((polyline_wire(value)?, glam::DMat4::IDENTITY.to_cols_array())), _ => embedded_revolve_profile(entity), } diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 16b66c16a..4c1c944a5 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -662,10 +662,98 @@ pub fn embedded_path(entity: &EntityType) -> Option { } } +/// The frame placing a plane's coordinates in the world: its axes, normal +/// and origin as matrix columns. +fn plane_frame(plane: &Plane) -> Option<[f64; 16]> { + let normal = plane.normal()?; + let column = |v: [f64; 3], w: f64| glam::DVec4::new(v[0], v[1], v[2], w); + Some(glam::DMat4::from_cols( + column(plane.x_axis, 0.0), + column(plane.y_axis, 0.0), + column(normal, 0.0), + column(plane.origin, 1.0), + ) + .to_cols_array()) +} + +/// A region of one boundary loop as the curve the reference records for it +/// as a profile: a lone circle or whole ellipse as itself, a loop of lines +/// and arcs as a closed polyline in the region's plane (with that plane's +/// frame), read from the region's modeler edges. +pub fn region_boundary_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { + let EntityType::Region(region) = entity else { return None }; + if !region.acis_data.has_data() { + return None; + } + let (plane, loops, _) = super::presspull_model::profile_geometry(entity)?; + let [boundary] = loops.as_slice() else { return None }; + let world = |v: [f64; 3]| Vector3::new(v[0], v[1], v[2]); + let normal = world(plane.normal()?); + let identity = glam::DMat4::IDENTITY.to_cols_array(); + match boundary.as_slice() { + [Curve::Circle(circle)] => { + let mut value = codec::entities::Circle::new(); + value.center = world(plane.point_at(circle.centre)); + value.radius = circle.radius; + value.normal = normal; + return Some((EmbeddedEntity::Circle(value), identity)); + } + [Curve::Ellipse(arc)] if (arc.sweep() - std::f64::consts::TAU).abs() <= 1e-9 => { + let ellipse = arc.ellipse; + let centre = Vec3::from(plane.point_at(ellipse.centre)); + let tip = Vec3::from(plane.point_at([ + ellipse.centre[0] + ellipse.major_axis[0] * ellipse.major_radius, + ellipse.centre[1] + ellipse.major_axis[1] * ellipse.major_radius, + ])); + let mut value = codec::entities::Ellipse::new(); + value.center = world(centre.to_array()); + value.major_axis = world((tip - centre).to_array()); + value.minor_axis_ratio = ellipse.minor_radius / ellipse.major_radius; + value.start_parameter = arc.start_parameter; + value.end_parameter = arc.start_parameter + std::f64::consts::TAU; + value.normal = normal; + return Some((EmbeddedEntity::Ellipse(value), identity)); + } + _ => {} + } + // Walk the loop head to tail; an arc run against its direction turns + // clockwise (negative bulge). + let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]) <= 1e-9 * (1.0 + a[0].abs().max(a[1].abs())); + let ends = |piece: &Curve| match piece { + Curve::Line(line) => Some((line.start, line.end, 0.0)), + Curve::Arc(arc) => { + let at = |angle: f64| [arc.centre[0] + arc.radius * angle.cos(), arc.centre[1] + arc.radius * angle.sin()]; + Some((at(arc.start_angle), at(arc.end_angle), (arc.sweep() / 4.0).tan())) + } + _ => None, + }; + let (first_start, first_end, _) = ends(boundary.first()?)?; + let second = boundary.get(1).and_then(ends); + let mut at = if second.is_some_and(|(start, end, _)| close(first_start, start) || close(first_start, end)) { + first_end + } else { + first_start + }; + let mut polyline = LwPolyline::new(); + for piece in boundary { + let (start, end, bulge) = ends(piece)?; + let (from, to, bulge) = if close(start, at) { (start, end, bulge) } else if close(end, at) { (end, start, -bulge) } else { return None }; + let mut vertex = LwVertex::new(Vector2::new(from[0], from[1])); + vertex.bulge = bulge; + polyline.vertices.push(vertex); + at = to; + } + polyline.is_closed = true; + Some((EmbeddedEntity::LwPolyline(polyline), plane_frame(&plane)?)) +} + fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { if region_with_holes(entity) { return None; } + if let Some(found) = region_boundary_entity(entity) { + return Some(found); + } if !matches!(entity, EntityType::Polyline3D(_)) { if let Some(embedded) = embedded_path(entity) { return Some((embedded, glam::DMat4::IDENTITY.to_cols_array())); From b701e22d28c3e1f9d6fea7f95f4ce72df0c9aab4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:08:36 +0300 Subject: [PATCH 7/9] Record a region profile polyline in its own coordinates The polyline round a region is written about the region's normal in its object coordinates with an identity transform, as the reference writes it; a rebuild from the history placed a polyline given in plane coordinates with a frame at the wrong place once the region moved. Arcs of one circle making a whole turn are recorded as that circle. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/sweep_command_model.rs | 2 +- src/scene/model/sweep_model.rs | 45 +++++++++++++++----------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index d6f3d0ba1..6b5f3dc43 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -674,7 +674,7 @@ pub fn assoc_source_supported(entity: &EntityType) -> bool { } /// The arbitrary-axis X direction of an entity normal. -fn arbitrary_x(normal: glam::DVec3) -> glam::DVec3 { +pub(crate) fn arbitrary_x(normal: glam::DVec3) -> glam::DVec3 { let world = if normal.x.abs() < 1.0 / 64.0 && normal.y.abs() < 1.0 / 64.0 { glam::DVec3::Y } else { glam::DVec3::Z }; world.cross(normal).normalize() } diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 4c1c944a5..22badf863 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -662,24 +662,11 @@ pub fn embedded_path(entity: &EntityType) -> Option { } } -/// The frame placing a plane's coordinates in the world: its axes, normal -/// and origin as matrix columns. -fn plane_frame(plane: &Plane) -> Option<[f64; 16]> { - let normal = plane.normal()?; - let column = |v: [f64; 3], w: f64| glam::DVec4::new(v[0], v[1], v[2], w); - Some(glam::DMat4::from_cols( - column(plane.x_axis, 0.0), - column(plane.y_axis, 0.0), - column(normal, 0.0), - column(plane.origin, 1.0), - ) - .to_cols_array()) -} - /// A region of one boundary loop as the curve the reference records for it -/// as a profile: a lone circle or whole ellipse as itself, a loop of lines -/// and arcs as a closed polyline in the region's plane (with that plane's -/// frame), read from the region's modeler edges. +/// as a profile: a circle (one edge, or arcs of one circle making a whole +/// turn) or whole ellipse as itself, a loop of lines and arcs as a closed +/// polyline in its own coordinate system about the region's normal, read +/// from the region's modeler edges. pub fn region_boundary_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { let EntityType::Region(region) = entity else { return None }; if !region.acis_data.has_data() { @@ -698,6 +685,19 @@ pub fn region_boundary_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f value.normal = normal; return Some((EmbeddedEntity::Circle(value), identity)); } + [Curve::Arc(first), rest @ ..] + if rest.iter().all(|piece| matches!(piece, Curve::Arc(arc) + if (arc.centre[0] - first.centre[0]).hypot(arc.centre[1] - first.centre[1]) <= 1e-9 * (1.0 + first.radius) + && (arc.radius - first.radius).abs() <= 1e-9 * (1.0 + first.radius))) + && (boundary.iter().map(|piece| match piece { Curve::Arc(arc) => arc.sweep(), _ => 0.0 }).sum::() + - std::f64::consts::TAU).abs() <= 1e-9 => + { + let mut value = codec::entities::Circle::new(); + value.center = world(plane.point_at(first.centre)); + value.radius = first.radius; + value.normal = normal; + return Some((EmbeddedEntity::Circle(value), identity)); + } [Curve::Ellipse(arc)] if (arc.sweep() - std::f64::consts::TAU).abs() <= 1e-9 => { let ellipse = arc.ellipse; let centre = Vec3::from(plane.point_at(ellipse.centre)); @@ -734,17 +734,24 @@ pub fn region_boundary_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f } else { first_start }; + // The polyline in its object coordinates: the arbitrary axes of the normal. + let axis_normal = glam::DVec3::new(normal.x, normal.y, normal.z); + let axis_x = super::sweep_command_model::arbitrary_x(axis_normal); + let axis_y = axis_normal.cross(axis_x); let mut polyline = LwPolyline::new(); + polyline.normal = normal; + polyline.elevation = glam::DVec3::from_array(plane.origin).dot(axis_normal); for piece in boundary { let (start, end, bulge) = ends(piece)?; let (from, to, bulge) = if close(start, at) { (start, end, bulge) } else if close(end, at) { (end, start, -bulge) } else { return None }; - let mut vertex = LwVertex::new(Vector2::new(from[0], from[1])); + let point = glam::DVec3::from_array(plane.point_at(from)); + let mut vertex = LwVertex::new(Vector2::new(point.dot(axis_x), point.dot(axis_y))); vertex.bulge = bulge; polyline.vertices.push(vertex); at = to; } polyline.is_closed = true; - Some((EmbeddedEntity::LwPolyline(polyline), plane_frame(&plane)?)) + Some((EmbeddedEntity::LwPolyline(polyline), identity)) } fn embedded_planar_entity(entity: &EntityType) -> Option<(EmbeddedEntity, [f64; 16])> { From a8acc077d7eca0e1e1128c6d2315c8a713bf60fb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:28:32 +0300 Subject: [PATCH 8/9] Keep no draft distances on an extruded surface The reference writes none; a draft distance on an extruded surface made it re-extrude the surface with a taper when the profile changed. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/solid_history.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index ad407fdff..ede446ada 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -840,8 +840,11 @@ pub fn extrusion_surface_data( sweep_entity: Some(profile), options: SurfaceSweepOptions { draft_angle: value.draft_angle, - draft_start_distance: value.start_draft_distance, - draft_end_distance: value.end_draft_distance, + // The reference keeps no draft distances on an extruded + // surface; a distance there reads as a taper when it + // re-extrudes the surface from a changed profile. + draft_start_distance: 0.0, + draft_end_distance: 0.0, twist_angle: value.twist_angle, scale_factor: value.scale_factor, align_angle: value.align_angle, From 71e6a12ebf87a844a9fed70c4c99ec0aa0545e58 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:29:33 +0300 Subject: [PATCH 9/9] Record a region loft section as its boundary curve A lofted surface's region cross sections are recorded as the curve round each region, as the reference records them; a region recorded as a region made the reference refuse to open the drawing. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/loft_command_model.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/scene/model/loft_command_model.rs b/src/scene/model/loft_command_model.rs index 9577096a8..7d1394374 100644 --- a/src/scene/model/loft_command_model.rs +++ b/src/scene/model/loft_command_model.rs @@ -9,7 +9,9 @@ use super::sweep_model::embedded_path; pub(crate) fn embedded_section(entity: &EntityType) -> Option { match entity { - EntityType::Region(region) => Some(EmbeddedEntity::Region(region.clone())), + // A region as the curve round it, as the reference records it. + EntityType::Region(region) => Some(super::sweep_model::region_boundary_entity(entity) + .map_or_else(|| EmbeddedEntity::Region(region.clone()), |(curve, _)| curve)), EntityType::Point(point) => Some(EmbeddedEntity::Point(point.clone())), _ => embedded_path(entity), }