fix: export interoperable DWG constraint graphs

This commit is contained in:
Hakan Seven committed 2026-09-14 02:57:33 +03:00
1 parent 7b4cef41f7
commit fc1788df65
7 files changed
+398 -201

No files matched your search

Generated
+3 -3
View File
@@ -81,7 +81,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.5.5" version = "0.5.5"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=6f3711809e39e9ffdf16a63086079b6475d97175#6f3711809e39e9ffdf16a63086079b6475d97175" source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=5eea24cf35ba1739c22e982fc398ddb6c285def5#5eea24cf35ba1739c22e982fc398ddb6c285def5"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"anyhow", "anyhow",
@@ -896,7 +896,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a2e7cee23d5b1fd6e1eef16118fa6db491febe8b#a2e7cee23d5b1fd6e1eef16118fa6db491febe8b" source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5f0cdffc0faa9db03346aa794e22285c365b5f7f#5f0cdffc0faa9db03346aa794e22285c365b5f7f"
dependencies = [ dependencies = [
"acadrust", "acadrust",
"cavalier_contours", "cavalier_contours",
@@ -907,7 +907,7 @@ dependencies = [
[[package]] [[package]]
name = "cadkernel-constraints" name = "cadkernel-constraints"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a2e7cee23d5b1fd6e1eef16118fa6db491febe8b#a2e7cee23d5b1fd6e1eef16118fa6db491febe8b" source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5f0cdffc0faa9db03346aa794e22285c365b5f7f#5f0cdffc0faa9db03346aa794e22285c365b5f7f"
dependencies = [ dependencies = [
"nalgebra", "nalgebra",
] ]
+3 -3
View File
@@ -31,9 +31,9 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17" rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] } clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11" env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a2e7cee23d5b1fd6e1eef16118fa6db491febe8b", features = ["acis", "offset"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5f0cdffc0faa9db03346aa794e22285c365b5f7f", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a2e7cee23d5b1fd6e1eef16118fa6db491febe8b" } cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5f0cdffc0faa9db03346aa794e22285c365b5f7f" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
+2 -2
View File
@@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed # Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine # `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply. # crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", optional = true, features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only). # Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true } interprocess = { version = "2", optional = true }
@@ -38,7 +38,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
cargo-lock = "11" cargo-lock = "11"
# acadrust is scanned at build time to generate the embedded type registry. # acadrust is scanned at build time to generate the embedded type registry.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
serde_json = "1" serde_json = "1"
+1 -1
View File
@@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5", features = ["serde"] }
bincode = "1.3" bincode = "1.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
+1 -1
View File
@@ -10,4 +10,4 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175", features = ["serde"] } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5", features = ["serde"] }
+1 -1
View File
@@ -12,4 +12,4 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "6f3711809e39e9ffdf16a63086079b6475d97175" } acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5eea24cf35ba1739c22e982fc398ddb6c285def5" }
+387 -190
View File
@@ -2,10 +2,10 @@
//! The application record remains authoritative when both representations exist. //! The application record remains authoritative when both representations exist.
use acadrust::objects::{ use acadrust::objects::{
Assoc2dConstraintGroup, AssocAction, AssocConstraintNode, AssocConstraintNodeData, Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode,
AssocDependency, AssocEvalValue, AssocEvalVariant, AssocGeomDependency, AssocNetwork, AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant,
AssocPersistentSubentId, AssocValueDependency, AssocVariable, AssociativeData, AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency,
AssociativeObject, ObjectType, AssocVariable, AssociativeData, AssociativeObject, ObjectType,
}; };
use acadrust::types::{Handle, Vector3}; use acadrust::types::{Handle, Vector3};
use acadrust::{CadDocument, EntityType}; use acadrust::{CadDocument, EntityType};
@@ -71,12 +71,23 @@ impl<'a> GroupBuilder<'a> {
} }
fn push_node(&mut self, node_id: i32, class_name: &'static str, data: AssocConstraintNodeData) { fn push_node(&mut self, node_id: i32, class_name: &'static str, data: AssocConstraintNodeData) {
let status = u8::from(matches!(
&data,
AssocConstraintNodeData::ImplicitPoint { .. }
| AssocConstraintNodeData::Point { .. }
| AssocConstraintNodeData::Line { .. }
| AssocConstraintNodeData::BoundedLine { .. }
| AssocConstraintNodeData::Circle { .. }
| AssocConstraintNodeData::Arc { .. }
| AssocConstraintNodeData::Ellipse { .. }
| AssocConstraintNodeData::BoundedEllipse { .. }
));
self.nodes.push(AssocConstraintNode { self.nodes.push(AssocConstraintNode {
node_id, node_id,
status: 1, status,
connections: Vec::new(), connections: Vec::new(),
class_name: class_name.to_string(), class_name: class_name.to_string(),
registry_flag: true, registry_flag: false,
data, data,
}); });
} }
@@ -117,7 +128,8 @@ impl<'a> GroupBuilder<'a> {
AssocConstraintNodeData::None, AssocConstraintNodeData::None,
); );
let last = self.nodes.last_mut().expect("just pushed"); let last = self.nodes.last_mut().expect("just pushed");
last.class_name = "ACCONSTRAINEDBOUNDEDLINE".to_string(); last.class_name = "AcConstrainedBoundedLine".to_string();
last.status = 1;
last.data = AssocConstraintNodeData::BoundedLine { last.data = AssocConstraintNodeData::BoundedLine {
geometry_dependency: Handle::NULL, geometry_dependency: Handle::NULL,
geometry_node_id: node_id, geometry_node_id: node_id,
@@ -133,7 +145,7 @@ impl<'a> GroupBuilder<'a> {
crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z)); crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z));
self.push_node( self.push_node(
node_id, node_id,
"ACCONSTRAINEDCIRCLE", "AcConstrainedCircle",
AssocConstraintNodeData::Circle { AssocConstraintNodeData::Circle {
geometry_dependency: Handle::NULL, geometry_dependency: Handle::NULL,
geometry_node_id: node_id, geometry_node_id: node_id,
@@ -159,7 +171,7 @@ impl<'a> GroupBuilder<'a> {
+ Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.end_angle.sin()); + Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.end_angle.sin());
self.push_node( self.push_node(
node_id, node_id,
"ACCONSTRAINEDARC", "AcConstrainedArc",
AssocConstraintNodeData::Arc { AssocConstraintNodeData::Arc {
geometry_dependency: Handle::NULL, geometry_dependency: Handle::NULL,
geometry_node_id: node_id, geometry_node_id: node_id,
@@ -204,7 +216,7 @@ impl<'a> GroupBuilder<'a> {
let node_id = self.alloc_node_id(); let node_id = self.alloc_node_id();
self.push_node( self.push_node(
node_id, node_id,
"ACCONSTRAINEDIMPLICITPOINT", "AcConstrainedImplicitPoint",
AssocConstraintNodeData::ImplicitPoint { AssocConstraintNodeData::ImplicitPoint {
geometry_dependency: Handle::NULL, geometry_dependency: Handle::NULL,
geometry_node_id: node_id, geometry_node_id: node_id,
@@ -245,28 +257,28 @@ impl<'a> GroupBuilder<'a> {
/// Class name for a plain `Geometrical`-shaped constraint. /// Class name for a plain `Geometrical`-shaped constraint.
fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> { fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> {
match kind { match kind {
ConstraintKind::Coincident => Some("ACPOINTCOINCIDENCECONSTRAINT"), ConstraintKind::Coincident => Some("AcPointCoincidenceConstraint"),
ConstraintKind::Horizontal => Some("ACHORIZONTALCONSTRAINT"), ConstraintKind::Horizontal => Some("AcHorizontalConstraint"),
ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"), ConstraintKind::Vertical => Some("AcVerticalConstraint"),
ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"), ConstraintKind::Perpendicular => Some("AcPerpendicularConstraint"),
ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"), ConstraintKind::Tangent => Some("AcTangentConstraint"),
ConstraintKind::Smooth => Some("ACG2SMOOTHCONSTRAINT"), ConstraintKind::Smooth => Some("AcG2SmoothConstraint"),
// the native format's own `GeomConstraintType` enum (native SDK's // the native format's own `GeomConstraintType` enum (native SDK's
// `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular` // `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular`
// as a distinct, real geometric-constraint kind. // as a distinct, real geometric-constraint kind.
ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"), ConstraintKind::Normal => Some("AcNormalConstraint"),
ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"), ConstraintKind::Concentric => Some("AcConcentricConstraint"),
ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"), ConstraintKind::CenterPoint => Some("AcCenterPointConstraint"),
ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"), ConstraintKind::Colinear => Some("AcColinearConstraint"),
ConstraintKind::Fixed => Some("ACFIXEDCONSTRAINT"), ConstraintKind::Fixed => Some("AcFixedConstraint"),
ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"), ConstraintKind::Midpoint => Some("AcMidPointConstraint"),
ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"), ConstraintKind::PointOnCurve => Some("AcPointCurveConstraint"),
ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"), ConstraintKind::Symmetric => Some("AcSymmetricConstraint"),
// Also a plain `Geometrical`-shaped node in the native format's own class list // Also a plain `Geometrical`-shaped node in the native format's own class list
// (`is_plain_geometrical_constraint`) — unlike `Equal`, its class // (`is_plain_geometrical_constraint`) — unlike `Equal`, its class
// name never branches on entity type, so it belongs here rather // name never branches on entity type, so it belongs here rather
// than in the caller's per-entity-type dispatch. // than in the caller's per-entity-type dispatch.
ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"), ConstraintKind::EqualDistance => Some("AcEqualDistanceConstraint"),
// `Equal` needs the resolved entity types, so the caller handles it. // `Equal` needs the resolved entity types, so the caller handles it.
ConstraintKind::Equal ConstraintKind::Equal
| ConstraintKind::Parallel | ConstraintKind::Parallel
@@ -295,10 +307,10 @@ fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> {
const fn dimensional_class_name(kind: ConstraintKind) -> &'static str { const fn dimensional_class_name(kind: ConstraintKind) -> &'static str {
match kind { match kind {
ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => { ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => {
"ACDISTANCECONSTRAINT" "AcDistanceConstraint"
} }
ConstraintKind::Angle => "ACANGLECONSTRAINT", ConstraintKind::Angle => "AcAngleConstraint",
ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT", ConstraintKind::Radius | ConstraintKind::Diameter => "AcRadiusDiameterConstraint",
_ => "", _ => "",
} }
} }
@@ -347,7 +359,7 @@ fn constraint_node(
let node_id = builder.alloc_node_id(); let node_id = builder.alloc_node_id();
builder.push_node( builder.push_node(
node_id, node_id,
"ACPARALLELCONSTRAINT", "AcParallelConstraint",
AssocConstraintNodeData::Parallel { AssocConstraintNodeData::Parallel {
owner_id: na, owner_id: na,
is_implied: false, is_implied: false,
@@ -365,9 +377,9 @@ fn constraint_node(
let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_))) let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_)))
&& matches!(document.get_entity(b.entity), Some(EntityType::Line(_))); && matches!(document.get_entity(b.entity), Some(EntityType::Line(_)));
let class_name = if both_lines { let class_name = if both_lines {
"ACEQUALLENGTHCONSTRAINT" "AcEqualLengthConstraint"
} else { } else {
"ACEQUALRADIUSCONSTRAINT" "AcEqualRadiusConstraint"
}; };
let node_id = builder.alloc_node_id(); let node_id = builder.alloc_node_id();
builder.push_node( builder.push_node(
@@ -477,14 +489,26 @@ fn constraint_node(
/// objects it references by handle, and the network/dictionary wiring. /// objects it references by handle, and the network/dictionary wiring.
struct Allocator<'a> { struct Allocator<'a> {
document: &'a mut CadDocument, document: &'a mut CadDocument,
/// One `AssocVariable` handle per distinct named parameter actually /// One shared variable per distinct parameter in this scope.
/// referenced in this scope, created once and shared by every
/// constraint that references the same name (mirrors how the native format's own
/// variables work — one object, many dependents).
variables: FxHashMap<String, Handle>, variables: FxHashMap<String, Handle>,
variable_actions: Vec<Handle>,
} }
impl Allocator<'_> { impl Allocator<'_> {
fn numeric_eval(value: f64) -> AssocEvalVariant {
if value.fract() == 0.0 && value >= i32::MIN as f64 && value <= i32::MAX as f64 {
AssocEvalVariant {
code: 90,
value: AssocEvalValue::Long(value as i32),
}
} else {
AssocEvalVariant {
code: 40,
value: AssocEvalValue::Real(value),
}
}
}
fn insert_associative( fn insert_associative(
&mut self, &mut self,
owner: Handle, owner: Handle,
@@ -523,21 +547,29 @@ impl Allocator<'_> {
.insert(handle, ObjectType::Associative(object)); .insert(handle, ObjectType::Associative(object));
} }
fn geom_dependency(&mut self, group_handle: Handle, entity: Handle) -> Handle { fn geom_dependency(
&mut self,
group_handle: Handle,
entity: Handle,
dependency_id: i32,
) -> Handle {
self.insert_associative( self.insert_associative(
group_handle, group_handle,
"ACDBASSOCGEOMDEPENDENCY", "ACDBASSOCGEOMDEPENDENCY",
"AcDbAssocGeomDependency", "AcDbAssocGeomDependency",
AssociativeData::GeomDependency(AssocGeomDependency { AssociativeData::GeomDependency(AssocGeomDependency {
dependency: AssocDependency { dependency: AssocDependency {
class_version: 1, class_version: 2,
is_read_dependency: true, is_read_dependency: true,
is_write_dependency: true, is_write_dependency: true,
is_attached_to_object: true, is_attached_to_object: true,
is_delegating_to_owning_action: true,
order: -10_000,
dependent_on: entity, dependent_on: entity,
dependency_body_id: dependency_id,
..Default::default() ..Default::default()
}, },
class_version: 1, class_version: 0,
enabled: true, enabled: true,
// Point and curve addressing lives on constraint-node data. // Point and curve addressing lives on constraint-node data.
persistent_subent: AssocPersistentSubentId::default(), persistent_subent: AssocPersistentSubentId::default(),
@@ -546,23 +578,33 @@ impl Allocator<'_> {
} }
/// Returns the `AssocVariable` handle for `name`, creating it once per scope. /// Returns the `AssocVariable` handle for `name`, creating it once per scope.
fn variable(&mut self, group_handle: Handle, name: &str, formula: &str) -> Handle { fn variable(
&mut self,
network_handle: Handle,
name: &str,
formula: &str,
resolved: f64,
) -> Handle {
if let Some(&handle) = self.variables.get(name) { if let Some(&handle) = self.variables.get(name) {
return handle; return handle;
} }
let handle = self.insert_associative( let handle = self.insert_associative(
group_handle, network_handle,
"ACDBASSOCVARIABLE", "ACDBASSOCVARIABLE",
"AcDbAssocVariable", "AcDbAssocVariable",
AssociativeData::Variable(AssocVariable { AssociativeData::Variable(AssocVariable {
action: AssocAction::default(), action: AssocAction {
class_version: 1, class_version: 2,
owning_network: network_handle,
action_index: self.variable_actions.len() as i32 + 1,
..Default::default()
},
class_version: 2,
name: name.to_string(), name: name.to_string(),
expression: formula.to_string(), expression: formula.to_string(),
// Empty selects the format's default expression evaluator. evaluator: "AcDbCalc:1.0".to_string(),
evaluator: String::new(),
description: String::new(), description: String::new(),
value: AssocEvalVariant::default(), value: Self::numeric_eval(resolved),
has_cached_value: false, has_cached_value: false,
cached_value: String::new(), cached_value: String::new(),
flag: false, flag: false,
@@ -570,38 +612,44 @@ impl Allocator<'_> {
}), }),
); );
self.variables.insert(name.to_string(), handle); self.variables.insert(name.to_string(), handle);
self.variable_actions.push(handle);
handle handle
} }
/// Uses a variable dependency for named values and a null dependency for
/// literal values.
fn value_dependency( fn value_dependency(
&mut self, &mut self,
group_handle: Handle, group_handle: Handle,
variable_handle: Option<Handle>, variable_handle: Handle,
resolved: f64, resolved: f64,
dependency_id: i32,
) -> Handle { ) -> Handle {
let value = AssocEvalVariant { let value = Self::numeric_eval(resolved);
code: 40, let handle = self.insert_associative(
value: AssocEvalValue::Real(resolved),
};
self.insert_associative(
group_handle, group_handle,
"ACDBASSOCVALUEDEPENDENCY", "ACDBASSOCVALUEDEPENDENCY",
"AcDbAssocValueDependency", "AcDbAssocValueDependency",
AssociativeData::ValueDependency(AssocValueDependency { AssociativeData::ValueDependency(AssocValueDependency {
dependency: AssocDependency { dependency: AssocDependency {
class_version: 1, class_version: 2,
is_read_dependency: true, is_read_dependency: true,
is_write_dependency: false, is_write_dependency: false,
dependent_on: variable_handle.unwrap_or(Handle::NULL), is_attached_to_object: true,
is_delegating_to_owning_action: true,
dependent_on: variable_handle,
dependency_body_id: dependency_id,
..Default::default() ..Default::default()
}, },
class_version: 1, class_version: 0,
name: String::new(), name: String::new(),
value, value,
}), }),
) );
if let Some(ObjectType::Associative(variable)) =
self.document.objects.get_mut(&variable_handle)
{
variable.reactors.push(handle);
}
handle
} }
} }
@@ -647,31 +695,21 @@ fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) {
} }
} }
/// The DWG/DXF CLASSES-table entries this module's five `AssociativeObject` /// Associative classes that must have real class numbers in DWG/DXF.
/// kinds need before they can be written with a real class number. const ASSOC_CLASSES: &[(&str, &str, i32)] = &[
/// Confirmed by reading the actual writer: `write_associative_object` (
/// resolves the class number via "ACDBASSOC2DCONSTRAINTGROUP",
/// `document.classes.get_by_name(&object.dxf_name)` "AcDbAssoc2dConstraintGroup",
/// (`io/dwg/dwg_stream_writers/object_writer/associative.rs:700-709`), and 45,
/// falls back to a generic type code 500 for every unregistered class alike ),
/// if it misses — which also means the CLASSES section written to the file ("ACDBASSOCNETWORK", "AcDbAssocNetwork", 45),
/// never declares them, so a reload can't dispatch the type-500 objects back ("ACDBASSOCVARIABLE", "AcDbAssocVariable", 45),
/// to `read_associative_object` either (`dwg_document_builder.rs`'s object ("ACDBASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency", 29),
/// dispatcher keys off exactly that class-number -> name mapping). A fresh ("ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", 29),
/// `CadDocument::initialize_defaults` only registers classes every drawing
/// needs, not ones specific to this feature, so this module registers its
/// own — the same pattern `CadDocument::register_object_context_class`
/// already uses for a different object family.
const ASSOC_CLASSES: &[(&str, &str)] = &[
("ASSOC2DCONSTRAINTGROUP", "AcDbAssoc2dConstraintGroup"),
("ASSOCNETWORK", "AcDbAssocNetwork"),
("ASSOCVARIABLE", "AcDbAssocVariable"),
("ASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency"),
("ASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency"),
]; ];
fn ensure_associative_classes_registered(document: &mut CadDocument) { fn ensure_associative_classes_registered(document: &mut CadDocument) {
for (dxf_name, cpp_class_name) in ASSOC_CLASSES { for (dxf_name, cpp_class_name, maintenance_version) in ASSOC_CLASSES {
if document.classes.get_by_name(dxf_name).is_some() { if document.classes.get_by_name(dxf_name).is_some() {
continue; continue;
} }
@@ -690,20 +728,15 @@ fn ensure_associative_classes_registered(document: &mut CadDocument) {
class_number: 0, class_number: 0,
// 499 = "object" (non-entity) — every class here is one. // 499 = "object" (non-entity) — every class here is one.
item_class_id: 0x1F3, item_class_id: 0x1F3,
dwg_version: 0, dwg_version: 27,
maintenance_version: 0, maintenance_version: *maintenance_version,
unknown1: 0, unknown1: 0,
unknown2: 0, unknown2: 0,
}); });
} }
} }
/// Ensures `owner`'s extension dictionary exists, returning its handle. /// Ensures `owner` has an extension dictionary through the public API.
/// `acadrust::CadDocument` only exposes creating one through
/// [`CadDocument::ensure_xrecord`] — the dictionary side map is private to
/// that crate. A throwaway XRecord is therefore created
/// and immediately removed purely to force that wiring through the one
/// public entry point that gets it right for both DWG and DXF.
fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle { fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Handle {
if let Some(handle) = document.extension_dictionary_handle(owner) { if let Some(handle) = document.extension_dictionary_handle(owner) {
return handle; return handle;
@@ -717,10 +750,7 @@ fn ensure_extension_dictionary(document: &mut CadDocument, owner: Handle) -> Han
dictionary_handle dictionary_handle
} }
/// Removes a named entry from `dictionary_handle`, and recursively removes /// Removes a dictionary entry and its owned object tree.
/// the object it pointed to — used both for the bootstrap XRecord above and
/// for clearing a stale `ACAD_ASSOCNETWORK` entry before a resave rebuilds
/// it from scratch.
fn remove_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str) { fn remove_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str) {
let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle)
else { else {
@@ -758,6 +788,37 @@ fn set_dictionary_entry(
} }
} }
fn ensure_global_network_dictionary(document: &mut CadDocument) -> Handle {
let root_handle = document.header.named_objects_dict_handle;
if let Some(ObjectType::Dictionary(root)) = document.objects.get(&root_handle) {
if let Some(handle) = root.get(NETWORK_DICTIONARY_KEY) {
if matches!(
document.objects.get(&handle),
Some(ObjectType::Dictionary(_))
) {
return handle;
}
}
}
remove_dictionary_entry(document, root_handle, NETWORK_DICTIONARY_KEY);
let dictionary_handle = document.allocate_handle();
let mut dictionary = acadrust::objects::Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = root_handle;
dictionary.reactors.push(root_handle);
document
.objects
.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
set_dictionary_entry(
document,
root_handle,
NETWORK_DICTIONARY_KEY,
dictionary_handle,
);
dictionary_handle
}
/// Removes `root` and every object transitively owned by it so rebuilding the /// Removes `root` and every object transitively owned by it so rebuilding the
/// graph cannot accumulate orphaned dependency or variable objects. /// graph cannot accumulate orphaned dependency or variable objects.
fn remove_owned_recursive(document: &mut CadDocument, root: Handle) { fn remove_owned_recursive(document: &mut CadDocument, root: Handle) {
@@ -776,14 +837,7 @@ fn remove_owned_recursive(document: &mut CadDocument, root: Handle) {
document.objects.remove(&root); document.objects.remove(&root);
} }
/// Whether `owner`'s scope already carries a materialized native graph — /// Whether `owner` already has a native graph that must stay synchronized.
/// from an earlier save with the setting on, or from a file that came from
/// real compatible applications. Drives the "sync-if-present, create-only-if-
/// enabled" rule in [`Scene::materialize_dwg_native_constraints_for_save`]:
/// a scope that already has this graph keeps getting it rebuilt on every
/// save regardless of the current setting, so turning the setting off never
/// leaves a stale, out-of-sync graph behind — only a scope that doesn't have
/// one yet is gated by the setting.
fn has_native_network(document: &CadDocument, owner: Handle) -> bool { fn has_native_network(document: &CadDocument, owner: Handle) -> bool {
let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else { let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else {
return false; return false;
@@ -802,13 +856,15 @@ fn materialize_scope(
owner: Handle, owner: Handle,
set: &SketchConstraintSet, set: &SketchConstraintSet,
parameters: &ParameterTable, parameters: &ParameterTable,
) { root_network_handle: Handle,
action_index: i32,
) -> Option<Handle> {
let dictionary_handle = ensure_extension_dictionary(document, owner); let dictionary_handle = ensure_extension_dictionary(document, owner);
remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY); remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY);
// Pass 1 (read-only): constraint-node shapes. // Pass 1 (read-only): constraint-node shapes.
let mut needs_value_dependency = Vec::new(); let mut needs_value_dependency = Vec::new();
let (mut nodes, entities) = { let (mut nodes, entities, referenced_entities) = {
let mut builder = GroupBuilder::new(document); let mut builder = GroupBuilder::new(document);
for constraint in &set.constraints { for constraint in &set.constraints {
if !constraint.enabled { if !constraint.enabled {
@@ -822,26 +878,24 @@ fn materialize_scope(
); );
} }
let GroupBuilder { let GroupBuilder {
nodes, entities, .. nodes,
entities,
referenced_entities,
..
} = builder; } = builder;
(nodes, entities) (nodes, entities, referenced_entities)
}; };
if nodes.is_empty() { if nodes.is_empty() {
return; return None;
} }
// Root pseudo-node (`nodes[0]`): confirmed mandatory by reading the DWG // The stream starts with one synthetic root before the registered nodes.
// writer — `Assoc2dConstraintGroup`'s write path only emits node data at
// all when `nodes.first()` is `Some`, so an empty `Vec` would skip node
// serialization entirely. This synthetic anchor is what makes every
// other node reach the file, not decoration.
let all_ids: Vec<i32> = nodes.iter().map(|n| n.node_id).collect();
nodes.insert( nodes.insert(
0, 0,
AssocConstraintNode { AssocConstraintNode {
node_id: 0, node_id: 0,
status: 1, status: 0,
connections: all_ids, connections: Vec::new(),
class_name: String::new(), class_name: String::new(),
registry_flag: false, registry_flag: false,
data: AssocConstraintNodeData::None, data: AssocConstraintNodeData::None,
@@ -856,13 +910,19 @@ fn materialize_scope(
let mut allocator = Allocator { let mut allocator = Allocator {
document, document,
variables: FxHashMap::default(), variables: FxHashMap::default(),
variable_actions: Vec::new(),
}; };
for (entity_handle, entity_nodes) in &entities { let mut geometry_dependencies = Vec::with_capacity(referenced_entities.len());
for (index, entity_handle) in referenced_entities.iter().enumerate() {
let Some(entity_nodes) = entities.get(entity_handle) else {
continue;
};
if entity_nodes.geometry_node_id == 0 { if entity_nodes.geometry_node_id == 0 {
continue; continue;
} }
let dep_handle = allocator.geom_dependency(group_handle, *entity_handle); let dep_handle = allocator.geom_dependency(group_handle, *entity_handle, index as i32 + 1);
geometry_dependencies.push(dep_handle);
if let Some(node) = nodes if let Some(node) = nodes
.iter_mut() .iter_mut()
.find(|n| n.node_id == entity_nodes.geometry_node_id) .find(|n| n.node_id == entity_nodes.geometry_node_id)
@@ -871,22 +931,27 @@ fn materialize_scope(
} }
} }
let mut group_dependencies = geometry_dependencies;
for (node_id, driving) in needs_value_dependency { for (node_id, driving) in needs_value_dependency {
let Some(driving) = driving else { continue }; let Some(driving) = driving else { continue };
let Ok(resolved) = driving.resolve(parameters) else { let Ok(resolved) = driving.resolve(parameters) else {
continue; continue;
}; };
let variable_handle = match &driving { let (name, formula) = match &driving {
DrivingValue::Named(name) => { DrivingValue::Named(name) => {
let formula = parameters let formula = parameters
.get(name) .get(name)
.map(|p| p.source.clone()) .map(|p| p.source.clone())
.unwrap_or_default(); .unwrap_or_default();
Some(allocator.variable(group_handle, name, &formula)) (name.clone(), formula)
} }
DrivingValue::Literal(_) => None, DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()),
}; };
let dep_handle = allocator.value_dependency(group_handle, variable_handle, resolved); let variable_handle = allocator.variable(network_handle, &name, &formula, resolved);
let dependency_id = group_dependencies.len() as i32 + 1;
let dep_handle =
allocator.value_dependency(group_handle, variable_handle, resolved, dependency_id);
group_dependencies.push(dep_handle);
if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) {
set_value_dependency(&mut node.data, dep_handle); set_value_dependency(&mut node.data, dep_handle);
} }
@@ -894,15 +959,18 @@ fn materialize_scope(
let group = Assoc2dConstraintGroup { let group = Assoc2dConstraintGroup {
action: AssocAction { action: AssocAction {
class_version: 2,
owning_network: network_handle, owning_network: network_handle,
action_index: allocator.variable_actions.len() as i32 + 1,
max_dependency_index: group_dependencies.len() as i32 + 1,
..Default::default() ..Default::default()
}, },
version: 1, version: 2,
flag: true, flag: false,
// Only world-XY entities reach native graph materialization. // Only world-XY entities reach native graph materialization.
work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y], work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y],
dependency: Handle::NULL, dependency: Handle::NULL,
actions: Vec::new(), actions: group_dependencies,
nodes, nodes,
}; };
allocator.insert_associative_at( allocator.insert_associative_at(
@@ -914,11 +982,25 @@ fn materialize_scope(
); );
let network = AssocNetwork { let network = AssocNetwork {
action: AssocAction::default(), action: AssocAction {
network_version: 1, class_version: 2,
network_action_index: 0, owning_network: root_network_handle,
actions: Vec::new(), action_index,
owned_actions: vec![group_handle], ..Default::default()
},
network_version: 0,
network_action_index: allocator.variable_actions.len() as i32 + 1,
actions: allocator
.variable_actions
.iter()
.copied()
.chain(std::iter::once(group_handle))
.map(|dependency| AssocActionDependency {
is_owned: true,
dependency,
})
.collect(),
owned_actions: Vec::new(),
}; };
allocator.insert_associative_at( allocator.insert_associative_at(
network_handle, network_handle,
@@ -927,6 +1009,11 @@ fn materialize_scope(
"AcDbAssocNetwork", "AcDbAssocNetwork",
AssociativeData::Network(network), AssociativeData::Network(network),
); );
if let Some(ObjectType::Associative(network)) =
allocator.document.objects.get_mut(&network_handle)
{
network.reactors.push(dictionary_handle);
}
set_dictionary_entry( set_dictionary_entry(
allocator.document, allocator.document,
@@ -934,6 +1021,7 @@ fn materialize_scope(
NETWORK_DICTIONARY_KEY, NETWORK_DICTIONARY_KEY,
network_handle, network_handle,
); );
Some(network_handle)
} }
impl Scene { impl Scene {
@@ -950,7 +1038,7 @@ impl Scene {
/// regardless of the current setting ([`has_native_network`]), so /// regardless of the current setting ([`has_native_network`]), so
/// toggling this off never leaves a stale graph sitting in the file. /// toggling this off never leaves a stale graph sitting in the file.
pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) { pub(crate) fn materialize_dwg_native_constraints_for_save(&mut self, enabled: bool) {
let mut classes_registered = false; let mut scopes = Vec::new();
for index in 0..self.sketch_constraints.len() { for index in 0..self.sketch_constraints.len() {
let set = self.sketch_constraints[index].clone(); let set = self.sketch_constraints[index].clone();
let owner = set.scope.owner_handle(&self.document); let owner = set.scope.owner_handle(&self.document);
@@ -960,12 +1048,70 @@ impl Scene {
if !enabled && !has_native_network(&self.document, owner) { if !enabled && !has_native_network(&self.document, owner) {
continue; continue;
} }
if !classes_registered { scopes.push((owner, set));
ensure_associative_classes_registered(&mut self.document);
classes_registered = true;
}
materialize_scope(&mut self.document, owner, &set, &self.named_parameters);
} }
if scopes.is_empty() {
return;
}
ensure_associative_classes_registered(&mut self.document);
let global_dictionary = ensure_global_network_dictionary(&mut self.document);
remove_dictionary_entry(
&mut self.document,
global_dictionary,
NETWORK_DICTIONARY_KEY,
);
let root_network_handle = self.document.allocate_handle();
let mut child_networks = Vec::new();
for (index, (owner, set)) in scopes.into_iter().enumerate() {
if let Some(handle) = materialize_scope(
&mut self.document,
owner,
&set,
&self.named_parameters,
root_network_handle,
index as i32 + 1,
) {
child_networks.push(handle);
}
}
if child_networks.is_empty() {
return;
}
let root_network = AssociativeObject {
handle: root_network_handle,
owner: global_dictionary,
reactors: vec![global_dictionary],
dxf_name: "ASSOCNETWORK".to_string(),
cpp_class_name: "AcDbAssocNetwork".to_string(),
data: AssociativeData::Network(AssocNetwork {
action: AssocAction {
class_version: 2,
..Default::default()
},
network_version: 0,
network_action_index: child_networks.len() as i32,
actions: child_networks
.into_iter()
.map(|dependency| AssocActionDependency {
is_owned: false,
dependency,
})
.collect(),
owned_actions: Vec::new(),
}),
..Default::default()
};
self.document
.objects
.insert(root_network_handle, ObjectType::Associative(root_network));
set_dictionary_entry(
&mut self.document,
global_dictionary,
NETWORK_DICTIONARY_KEY,
root_network_handle,
);
} }
} }
@@ -998,13 +1144,7 @@ mod tests {
))) )))
} }
/// Digs a scope's materialized native graph back out of `document`: fn native_group_handle(document: &CadDocument, owner: Handle) -> Handle {
/// `owner`'s extension dictionary -> `ACAD_ASSOCNETWORK` entry ->
/// its one owned action, which (for this module) is always the
/// `Assoc2dConstraintGroup` itself. Panics with a descriptive message on
/// any broken link, since every test using this expects the graph to be
/// present and well-formed.
fn native_group(document: &CadDocument, owner: Handle) -> &Assoc2dConstraintGroup {
let dict = document let dict = document
.extension_dictionary_handle(owner) .extension_dictionary_handle(owner)
.expect("extension dictionary should exist"); .expect("extension dictionary should exist");
@@ -1021,10 +1161,25 @@ mod tests {
else { else {
panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle"); panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle");
}; };
let group_handle = *network network
.owned_actions .actions
.first() .iter()
.expect("network should own the constraint group"); .filter(|dependency| dependency.is_owned)
.map(|dependency| dependency.dependency)
.find(|handle| {
matches!(
document.objects.get(handle),
Some(ObjectType::Associative(AssociativeObject {
data: AssociativeData::ConstraintGroup(_),
..
}))
)
})
.expect("network should own the constraint group")
}
fn native_group(document: &CadDocument, owner: Handle) -> &Assoc2dConstraintGroup {
let group_handle = native_group_handle(document, owner);
let Some(ObjectType::Associative(AssociativeObject { let Some(ObjectType::Associative(AssociativeObject {
data: AssociativeData::ConstraintGroup(group), data: AssociativeData::ConstraintGroup(group),
.. ..
@@ -1039,7 +1194,7 @@ mod tests {
#[test] #[test]
fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() { fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() {
for ext in ["dxf", "dwg"] { for ext in ["dwg", "dxf"] {
let mut scene = Scene::new(); let mut scene = Scene::new();
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0));
@@ -1071,17 +1226,17 @@ mod tests {
let class_names: Vec<&str> = let class_names: Vec<&str> =
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
assert!( assert!(
class_names.contains(&"ACHORIZONTALCONSTRAINT"), class_names.contains(&"AcHorizontalConstraint"),
"{ext}: missing horizontal constraint node, got {class_names:?}" "{ext}: missing horizontal constraint node, got {class_names:?}"
); );
assert!( assert!(
class_names.contains(&"ACVERTICALCONSTRAINT"), class_names.contains(&"AcVerticalConstraint"),
"{ext}: missing vertical constraint node, got {class_names:?}" "{ext}: missing vertical constraint node, got {class_names:?}"
); );
assert_eq!( assert_eq!(
class_names class_names
.iter() .iter()
.filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE") .filter(|&&n| n == "AcConstrainedBoundedLine")
.count(), .count(),
2, 2,
"{ext}: expected both lines' geometry nodes to survive, got {class_names:?}" "{ext}: expected both lines' geometry nodes to survive, got {class_names:?}"
@@ -1136,13 +1291,14 @@ mod tests {
let reloaded = crate::io::load_bytes("dwg_native_named_param.dwg", bytes) let reloaded = crate::io::load_bytes("dwg_native_named_param.dwg", bytes)
.unwrap_or_else(|e| panic!("reload dwg: {e}")); .unwrap_or_else(|e| panic!("reload dwg: {e}"));
let group_handle = native_group_handle(&reloaded, owner);
let group = native_group(&reloaded, owner); let group = native_group(&reloaded, owner);
assert!( assert!(
group group
.nodes .nodes
.iter() .iter()
.any(|n| n.class_name == "ACDISTANCECONSTRAINT"), .any(|n| n.class_name == "AcDistanceConstraint"),
"expected an ACDISTANCECONSTRAINT node, got {:?}", "expected a distance constraint node, got {:?}",
group group
.nodes .nodes
.iter() .iter()
@@ -1153,24 +1309,65 @@ mod tests {
has_variable_named(&reloaded, "gap"), has_variable_named(&reloaded, "gap"),
"expected the AssocVariable \"gap\" to survive a DWG round trip" "expected the AssocVariable \"gap\" to survive a DWG round trip"
); );
assert_eq!(group.action.class_version, 2);
assert_eq!(group.action.action_index, 2);
assert_eq!(group.action.max_dependency_index, 4);
assert_eq!(group.actions.len(), 3);
let value_dependency = group
.nodes
.iter()
.find_map(|node| match &node.data {
AssocConstraintNodeData::Distance {
value_dependency, ..
} => Some(*value_dependency),
_ => None,
})
.expect("distance node should reference its value dependency");
let Some(ObjectType::Associative(AssociativeObject {
owner: value_owner,
data: AssociativeData::ValueDependency(value),
..
})) = reloaded.objects.get(&value_dependency)
else {
panic!("value dependency should exist");
};
assert_eq!(*value_owner, group_handle);
assert_eq!(value.dependency.dependency_body_id, 3);
assert!(value.dependency.is_read_dependency);
assert!(!value.dependency.is_write_dependency);
let variable_handle = value.dependency.dependent_on;
let Some(ObjectType::Associative(AssociativeObject {
owner: network_handle,
reactors,
data: AssociativeData::Variable(variable),
..
})) = reloaded.objects.get(&variable_handle)
else {
panic!("named variable should exist");
};
assert_eq!(group.action.owning_network, *network_handle);
assert_eq!(variable.action.owning_network, *network_handle);
assert_eq!(variable.action.action_index, 1);
assert!(reactors.contains(&value_dependency));
let Some(ObjectType::Associative(AssociativeObject {
data: AssociativeData::Network(network),
..
})) = reloaded.objects.get(network_handle)
else {
panic!("scope network should exist");
};
assert_eq!(network.network_action_index, 2);
assert_eq!(network.actions.len(), 2);
assert_eq!(network.actions[0].dependency, variable_handle);
assert_eq!(network.actions[1].dependency, group_handle);
assert!(network.actions.iter().all(|action| action.is_owned));
} }
/// Confirmed by reading the actual DXF writer /// DXF currently keeps the graph shell but omits dependency objects.
/// (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`,
/// called from its `ObjectType::Associative` write arm): `acadrust`'s DXF
/// path deliberately does not write `AssocDependency`/
/// `AssocValueDependency`/`AssocGeomDependency`/`AssocVariable` objects at
/// all ("Unrestorable associative-framework objects are not written") —
/// DWG has no equivalent filter. So a DXF save keeps the
/// `Assoc2dConstraintGroup`/`AssocNetwork` shell (and every constraint
/// node's own embedded geometry — real coordinates, not lost), but each
/// node's `geometry_dependency`/`value_dependency` handle points at
/// nothing once reloaded, and no `AssocVariable` survives at all. This is
/// a real, upstream `acadrust` limitation, not something this module can
/// work around without patching that dependency — out of scope here (see
/// this project's stated preference, `named_parameters_persist.rs`, to
/// pick a different approach rather than patch `acadrust`). DWG is
/// therefore the only format this feature round-trips fully through.
#[test] #[test]
fn a_dxf_save_keeps_only_the_constraint_group_shell() { fn a_dxf_save_keeps_only_the_constraint_group_shell() {
let (mut scene, owner) = distance_with_named_parameter_scene(); let (mut scene, owner) = distance_with_named_parameter_scene();
@@ -1186,8 +1383,8 @@ mod tests {
group group
.nodes .nodes
.iter() .iter()
.any(|n| n.class_name == "ACDISTANCECONSTRAINT"), .any(|n| n.class_name == "AcDistanceConstraint"),
"expected the ACDISTANCECONSTRAINT node's shell to survive, got {:?}", "expected the distance constraint node's shell to survive, got {:?}",
group group
.nodes .nodes
.iter() .iter()
@@ -1285,7 +1482,7 @@ mod tests {
let owner = scene.document.header.model_space_block_handle; let owner = scene.document.header.model_space_block_handle;
let group = native_group(&scene.document, owner); let group = native_group(&scene.document, owner);
assert!( assert!(
group.nodes.iter().any(|n| n.class_name == "ACVERTICALCONSTRAINT"), group.nodes.iter().any(|n| n.class_name == "AcVerticalConstraint"),
"the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}", "the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}",
group.nodes.iter().map(|n| &n.class_name).collect::<Vec<_>>() group.nodes.iter().map(|n| &n.class_name).collect::<Vec<_>>()
); );
@@ -1393,15 +1590,15 @@ mod tests {
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
for expected in [ for expected in [
"ACCONCENTRICCONSTRAINT", "AcConcentricConstraint",
"ACCOLINEARCONSTRAINT", "AcColinearConstraint",
"ACFIXEDCONSTRAINT", "AcFixedConstraint",
"ACCENTERPOINTCONSTRAINT", "AcCenterPointConstraint",
"ACMIDPOINTCONSTRAINT", "AcMidPointConstraint",
"ACPOINTCURVECONSTRAINT", "AcPointCurveConstraint",
"ACEQUALDISTANCECONSTRAINT", "AcEqualDistanceConstraint",
"ACSYMMETRICCONSTRAINT", "AcSymmetricConstraint",
"ACNORMALCONSTRAINT", "AcNormalConstraint",
] { ] {
assert!( assert!(
class_names.contains(&expected), class_names.contains(&expected),
@@ -1413,7 +1610,7 @@ mod tests {
/// Constraint-parity Phase 1: an Arc registers in the solver as its own /// Constraint-parity Phase 1: an Arc registers in the solver as its own
/// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and /// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and
/// this module already had `ACCONSTRAINEDARC` geometry-dependency /// this module already had constrained-arc geometry-dependency
/// support in place before that. This confirms the two sides actually /// support in place before that. This confirms the two sides actually
/// meet: a Concentric/Tangent/Radius constraint referencing an arc /// meet: a Concentric/Tangent/Radius constraint referencing an arc
/// still gets its expected class names AND its geometry node is the /// still gets its expected class names AND its geometry node is the
@@ -1450,18 +1647,18 @@ mod tests {
let class_names: Vec<&str> = let class_names: Vec<&str> =
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
assert!( assert!(
class_names.contains(&"ACCONCENTRICCONSTRAINT"), class_names.contains(&"AcConcentricConstraint"),
"{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}" "{ext}: missing concentric constraint, got {class_names:?}"
); );
assert!( assert!(
class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), class_names.contains(&"AcRadiusDiameterConstraint"),
"{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" "{ext}: missing radius/diameter constraint, got {class_names:?}"
); );
assert!( assert!(
group group
.nodes .nodes
.iter() .iter()
.any(|n| n.class_name == "ACCONSTRAINEDARC"), .any(|n| n.class_name == "AcConstrainedArc"),
"{ext}: the arc should have its own geometry node, got {class_names:?}" "{ext}: the arc should have its own geometry node, got {class_names:?}"
); );
} }
@@ -1501,14 +1698,14 @@ mod tests {
let class_names: Vec<&str> = let class_names: Vec<&str> =
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
assert!( assert!(
class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), class_names.contains(&"AcPointCoincidenceConstraint"),
"{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}" "{ext}: missing point-coincidence constraint, got {class_names:?}"
); );
assert!( assert!(
group group
.nodes .nodes
.iter() .iter()
.any(|n| n.class_name == "ACCONSTRAINEDARC"), .any(|n| n.class_name == "AcConstrainedArc"),
"{ext}: the arc should still have its own geometry node, got {class_names:?}" "{ext}: the arc should still have its own geometry node, got {class_names:?}"
); );
} }
@@ -1556,11 +1753,11 @@ mod tests {
let class_names: Vec<&str> = let class_names: Vec<&str> =
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
assert!( assert!(
class_names.contains(&"ACFIXEDCONSTRAINT"), class_names.contains(&"AcFixedConstraint"),
"{ext}: the other constraint should still persist, got {class_names:?}" "{ext}: the other constraint should still persist, got {class_names:?}"
); );
assert!( assert!(
!class_names.contains(&"ACCONCENTRICCONSTRAINT"), !class_names.contains(&"AcConcentricConstraint"),
"{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}" "{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}"
); );
@@ -1622,8 +1819,8 @@ mod tests {
let class_names: Vec<&str> = let class_names: Vec<&str> =
group.nodes.iter().map(|n| n.class_name.as_str()).collect(); group.nodes.iter().map(|n| n.class_name.as_str()).collect();
assert!( assert!(
class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), class_names.contains(&"AcRadiusDiameterConstraint"),
"{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}" "{ext}: missing radius/diameter constraint, got {class_names:?}"
); );
assert!( assert!(
!class_names.iter().any(|n| n.contains("ARCLENGTH")), !class_names.iter().any(|n| n.contains("ARCLENGTH")),