forked from 1b-agent/opencadstudio-upstream
Arcs are now full constraint participants (center/radius via the existing Circle-shaped solver path, plus real endpoints kept consistent by arc-rules CurveValue constraints); adds Diameter/DistanceX/DistanceY/ArcLength/Normal constraint kinds and initial Ellipse entity support (center-based constraints only). DWG-native class mappings and gaps (ArcLength, Ellipse) are grounded against real ObjectARX headers rather than guessed, so files saved with DWG-native constraints stay honest about what AutoCAD can actually represent. Also fixes a case-sensitivity bug where a lowercase named-parameter reference typed at a dimensional constraint's value prompt was rejected (the command line uppercases SingleToken input before the parameter-name lookup), and adds a "Used by" column to the Named Parameters panel. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1521 lines
72 KiB
Rust
1521 lines
72 KiB
Rust
//! Additive DWG/DXF-native constraint persistence
|
|
//! (`docs/dwg_constraint_compatibility_design.md`).
|
|
//!
|
|
//! Writes the `acadrust`-modeled `AssocNetwork`/`AssocAction`/
|
|
//! `Assoc2dConstraintGroup` object graph alongside — not instead of — the
|
|
//! existing `OCS_SKETCH_CONSTRAINTS` XRecord (`sketch_persist.rs`), so a
|
|
//! saved file also carries constraints AutoCAD/BricsCAD recognize as their
|
|
//! own native objects. `sketch_persist.rs` stays the format this app reads
|
|
//! back on open; this module is one-way (write-only) for now — see the
|
|
//! design doc §6 for why (our model can carry things, like
|
|
//! `DrivingValue::Named`, the native graph doesn't perfectly round-trip
|
|
//! today) and §9 for what's confirmed vs. still unverified against real
|
|
//! AutoCAD/BricsCAD (nothing here has been opened in either — verification
|
|
//! so far is a real DWG+DXF round trip through `acadrust`'s own reader,
|
|
//! which confirms self-consistency, not real-world compatibility).
|
|
//!
|
|
//! **DWG is the only format this graph round-trips through with its
|
|
//! dependency chain intact.** Confirmed by reading `acadrust`'s own DXF
|
|
//! writer (`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`,
|
|
//! consulted from its `ObjectType::Associative` write arm): it deliberately
|
|
//! does not write `AssocDependency`/`AssocValueDependency`/
|
|
//! `AssocGeomDependency`/`AssocVariable` objects to DXF at all
|
|
//! ("Unrestorable associative-framework objects are not written") — DWG has
|
|
//! no equivalent filter. A DXF save still keeps the
|
|
//! `Assoc2dConstraintGroup`/`AssocNetwork` shell and every constraint node's
|
|
//! own embedded geometry (real coordinates, written directly on the node —
|
|
//! not lost), but each node's `geometry_dependency`/`value_dependency`
|
|
//! handle points at nothing once reloaded, and no `AssocVariable` survives.
|
|
//! This module still materializes the same graph for both formats rather
|
|
//! than special-casing DXF to write less: the shell that does survive is
|
|
//! strictly more than nothing, dangling handles are an ordinary, tolerated
|
|
//! pattern in both formats (e.g. a reference to a purged object), and
|
|
//! working around an upstream limitation by patching `acadrust` is out of
|
|
//! scope (this project's stated preference — see `named_parameters_persist.rs`).
|
|
//! See the test module for the DWG-vs-DXF difference demonstrated directly.
|
|
|
|
use acadrust::objects::{
|
|
Assoc2dConstraintGroup, AssocAction, AssocConstraintNode,
|
|
AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant,
|
|
AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency,
|
|
AssocVariable, AssociativeData, AssociativeObject, ObjectType,
|
|
};
|
|
use acadrust::types::{Handle, Vector3};
|
|
use acadrust::{CadDocument, EntityType};
|
|
use rustc_hash::FxHashMap;
|
|
|
|
use super::named_parameters::{DrivingValue, ParameterTable};
|
|
use super::sketch_constraints::{ConstraintKind, SketchConstraint, SketchConstraintSet, SketchRef};
|
|
use super::Scene;
|
|
|
|
/// The default sub-dictionary key `AcDbAssocNetwork::getInstanceFromObject`
|
|
/// uses when none is given — confirmed from Autodesk's own ObjectARX
|
|
/// reference (design doc §2b/§7.1), not guessed.
|
|
const NETWORK_DICTIONARY_KEY: &str = "ACAD_ASSOCNETWORK";
|
|
|
|
/// `AcConstraintGroupNode::GroupNodeId::kNullGroupNodeId` — node id 0 is
|
|
/// reserved as "no node", so real node ids start at 1.
|
|
const FIRST_NODE_ID: i32 = 1;
|
|
|
|
/// `AcDb::ImplicitPointType` (design doc §4/§7.2) — confirmed from
|
|
/// Autodesk's live ObjectARX reference.
|
|
mod implicit_point_type {
|
|
pub const START: u8 = 0;
|
|
pub const END: u8 = 1;
|
|
#[allow(dead_code)] // reserved for SketchRef marker -2, not wired up yet
|
|
pub const MID: u8 = 2;
|
|
pub const CENTER: u8 = 3;
|
|
}
|
|
|
|
/// One entity's constraint-group presence: its geometry node's id, plus
|
|
/// whichever `ImplicitPoint` nodes have been created for it so far, keyed
|
|
/// by our own `SketchRef::marker` convention so a second constraint
|
|
/// referencing the same point reuses the same node instead of duplicating it.
|
|
#[derive(Default)]
|
|
struct EntityNodes {
|
|
geometry_node_id: i32,
|
|
points: FxHashMap<i32, i32>,
|
|
}
|
|
|
|
/// Builds one scope's `Assoc2dConstraintGroup` graph incrementally,
|
|
/// allocating node ids and deduplicating geometry/point nodes per entity.
|
|
struct GroupBuilder<'a> {
|
|
document: &'a CadDocument,
|
|
nodes: Vec<AssocConstraintNode>,
|
|
next_node_id: i32,
|
|
entities: FxHashMap<Handle, EntityNodes>,
|
|
/// Real entity handles that ended up with a geometry node — what
|
|
/// `AssocGeomDependency` objects get created for, in first-touch order.
|
|
referenced_entities: Vec<Handle>,
|
|
}
|
|
|
|
impl<'a> GroupBuilder<'a> {
|
|
fn new(document: &'a CadDocument) -> Self {
|
|
Self {
|
|
document,
|
|
nodes: Vec::new(),
|
|
next_node_id: FIRST_NODE_ID,
|
|
entities: FxHashMap::default(),
|
|
referenced_entities: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn alloc_node_id(&mut self) -> i32 {
|
|
let id = self.next_node_id;
|
|
self.next_node_id += 1;
|
|
id
|
|
}
|
|
|
|
fn push_node(&mut self, node_id: i32, class_name: &'static str, data: AssocConstraintNodeData) {
|
|
self.nodes.push(AssocConstraintNode {
|
|
node_id,
|
|
status: 1,
|
|
connections: Vec::new(),
|
|
class_name: class_name.to_string(),
|
|
registry_flag: true,
|
|
data,
|
|
});
|
|
}
|
|
|
|
/// The geometry node id for `entity`'s whole shape, creating a `Line`
|
|
/// (well: `BoundedLine`, §4 — nothing we draw is an infinite
|
|
/// construction line), `Circle`, or `Arc` node the first time this
|
|
/// entity is referenced. Returns `None` for an entity kind this pass
|
|
/// doesn't support as constraint geometry — currently including
|
|
/// `Ellipse` (constraint-parity Phase 5): the solver
|
|
/// (`sketch_solve.rs`) and this app's own XRecord format both support
|
|
/// ellipse-referencing constraints already, but `acadrust`'s
|
|
/// `AssocConstraintNodeData::Ellipse`/`BoundedEllipse` variants have a
|
|
/// different field shape from `Circle`/`Arc`'s here (`owner_id`/
|
|
/// `is_implied`/`is_active` instead of `geometry_dependency`/
|
|
/// `geometry_node_id`, i.e. they don't obviously look like the same
|
|
/// "geometry dependency for an entity reference" role) — without
|
|
/// confirming that against a real AutoCAD-written file, guessing would
|
|
/// risk writing a wrong-but-plausible-looking object graph, which is
|
|
/// worse than the honest "not persisted to DWG yet" gap this already
|
|
/// degrades to (same contract as `ArcLength`, which has no native
|
|
/// representation at all).
|
|
fn geometry_node(&mut self, handle: Handle) -> Option<i32> {
|
|
if let Some(existing) = self.entities.get(&handle) {
|
|
if existing.geometry_node_id != 0 {
|
|
return Some(existing.geometry_node_id);
|
|
}
|
|
}
|
|
let entity = self.document.get_entity(handle)?;
|
|
let node_id = self.alloc_node_id();
|
|
match entity {
|
|
EntityType::Line(l) => {
|
|
let point = l.start;
|
|
let direction = (l.end - l.start).normalize();
|
|
self.push_node(
|
|
node_id,
|
|
"AcDbAssocGeomDependency", // placeholder, overwritten below
|
|
AssocConstraintNodeData::None,
|
|
);
|
|
let last = self.nodes.last_mut().expect("just pushed");
|
|
last.class_name = "ACCONSTRAINEDBOUNDEDLINE".to_string();
|
|
last.data = AssocConstraintNodeData::BoundedLine {
|
|
geometry_dependency: Handle::NULL,
|
|
geometry_node_id: node_id,
|
|
point,
|
|
direction,
|
|
is_ray: false,
|
|
start_point: l.start,
|
|
end_point: l.end,
|
|
};
|
|
}
|
|
EntityType::Circle(c) => {
|
|
let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((c.normal.x, c.normal.y, c.normal.z));
|
|
self.push_node(
|
|
node_id,
|
|
"ACCONSTRAINEDCIRCLE",
|
|
AssocConstraintNodeData::Circle {
|
|
geometry_dependency: Handle::NULL,
|
|
geometry_node_id: node_id,
|
|
center: c.center,
|
|
normal: c.normal,
|
|
direction: Vector3::new(axis_x.0, axis_x.1, axis_x.2),
|
|
radius: c.radius,
|
|
start_parameter: 0.0,
|
|
end_parameter: std::f64::consts::TAU,
|
|
reserved: 0.0,
|
|
},
|
|
);
|
|
}
|
|
EntityType::Arc(a) => {
|
|
let (axis_x, _axis_y) = crate::scene::view::transform::ocs_axes((a.normal.x, a.normal.y, a.normal.z));
|
|
let direction = Vector3::new(axis_x.0, axis_x.1, axis_x.2);
|
|
let start_point = a.center
|
|
+ Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.start_angle.cos())
|
|
+ Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.start_angle.sin());
|
|
let end_point = a.center
|
|
+ Vector3::new(axis_x.0, axis_x.1, axis_x.2) * (a.radius * a.end_angle.cos())
|
|
+ Vector3::new(-axis_x.1, axis_x.0, 0.0) * (a.radius * a.end_angle.sin());
|
|
self.push_node(
|
|
node_id,
|
|
"ACCONSTRAINEDARC",
|
|
AssocConstraintNodeData::Arc {
|
|
geometry_dependency: Handle::NULL,
|
|
geometry_node_id: node_id,
|
|
center: a.center,
|
|
normal: a.normal,
|
|
direction,
|
|
radius: a.radius,
|
|
start_parameter: a.start_angle,
|
|
end_parameter: a.end_angle,
|
|
reserved: 0.0,
|
|
start_point,
|
|
end_point,
|
|
},
|
|
);
|
|
}
|
|
_ => return None,
|
|
}
|
|
self.entities.entry(handle).or_default().geometry_node_id = node_id;
|
|
if !self.referenced_entities.contains(&handle) {
|
|
self.referenced_entities.push(handle);
|
|
}
|
|
Some(node_id)
|
|
}
|
|
|
|
/// The `ImplicitPoint` node id for one marker on `entity` (line
|
|
/// start/end, circle/arc center — design doc §4's confirmed
|
|
/// `AcDb::ImplicitPointType` mapping), creating it the first time this
|
|
/// exact (entity, marker) pair is referenced.
|
|
fn point_node(&mut self, handle: Handle, marker: i32) -> Option<i32> {
|
|
if let Some(existing) = self.entities.get(&handle).and_then(|e| e.points.get(&marker)) {
|
|
return Some(*existing);
|
|
}
|
|
let curve_id = self.geometry_node(handle)?;
|
|
let point_type = match marker {
|
|
0 => implicit_point_type::START,
|
|
1 => implicit_point_type::END,
|
|
-3 => implicit_point_type::CENTER,
|
|
_ => return None,
|
|
};
|
|
let node_id = self.alloc_node_id();
|
|
self.push_node(
|
|
node_id,
|
|
"ACCONSTRAINEDIMPLICITPOINT",
|
|
AssocConstraintNodeData::ImplicitPoint {
|
|
geometry_dependency: Handle::NULL,
|
|
geometry_node_id: node_id,
|
|
point: None,
|
|
point_type,
|
|
point_index: -1,
|
|
curve_id,
|
|
},
|
|
);
|
|
self.entities.entry(handle).or_default().points.insert(marker, node_id);
|
|
Some(node_id)
|
|
}
|
|
|
|
/// The node id `SketchRef` resolves to: a point node for a marked
|
|
/// reference, the whole geometry node otherwise.
|
|
fn ref_node(&mut self, r: SketchRef) -> Option<i32> {
|
|
match r.marker {
|
|
Some(marker) => self.point_node(r.entity, marker),
|
|
None => self.geometry_node(r.entity),
|
|
}
|
|
}
|
|
|
|
/// Records an undirected edge (design doc's confirmed reading of
|
|
/// `AcConstraintGroupNode`: "connection between two nodes is not
|
|
/// directed") between two node ids, on both nodes' own `connections`.
|
|
fn connect(&mut self, a: i32, b: i32) {
|
|
if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == a) {
|
|
node.connections.push(b);
|
|
}
|
|
if let Some(node) = self.nodes.iter_mut().find(|n| n.node_id == b) {
|
|
node.connections.push(a);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Class name for a plain `Geometrical`-shaped constraint — the
|
|
/// `is_plain_geometrical_constraint` list confirmed in
|
|
/// `dwg_stream_readers/object_reader/associative.rs` (design doc §3).
|
|
fn geometrical_class_name(kind: ConstraintKind) -> Option<&'static str> {
|
|
match kind {
|
|
ConstraintKind::Coincident => Some("ACPOINTCOINCIDENCECONSTRAINT"),
|
|
ConstraintKind::Horizontal => Some("ACHORIZONTALCONSTRAINT"),
|
|
ConstraintKind::Vertical => Some("ACVERTICALCONSTRAINT"),
|
|
ConstraintKind::Perpendicular => Some("ACPERPENDICULARCONSTRAINT"),
|
|
ConstraintKind::Tangent => Some("ACTANGENTCONSTRAINT"),
|
|
// AutoCAD's own `GeomConstraintType` enum (ObjectARX's
|
|
// `AcGeomConstraint.h`) lists `kNormal` alongside `kPerpendicular`
|
|
// as a distinct, real geometric-constraint kind.
|
|
ConstraintKind::Normal => Some("ACNORMALCONSTRAINT"),
|
|
ConstraintKind::Concentric => Some("ACCONCENTRICCONSTRAINT"),
|
|
ConstraintKind::CenterPoint => Some("ACCENTERPOINTCONSTRAINT"),
|
|
ConstraintKind::Colinear => Some("ACCOLINEARCONSTRAINT"),
|
|
ConstraintKind::Fixed => Some("ACFIXEDCONSTRAINT"),
|
|
ConstraintKind::Midpoint => Some("ACMIDPOINTCONSTRAINT"),
|
|
ConstraintKind::PointOnCurve => Some("ACPOINTCURVECONSTRAINT"),
|
|
ConstraintKind::Symmetric => Some("ACSYMMETRICCONSTRAINT"),
|
|
// Also a plain `Geometrical`-shaped node in AutoCAD's own class list
|
|
// (`is_plain_geometrical_constraint`) — unlike `Equal`, its class
|
|
// name never branches on entity type, so it belongs here rather
|
|
// than in the caller's per-entity-type dispatch.
|
|
ConstraintKind::EqualDistance => Some("ACEQUALDISTANCECONSTRAINT"),
|
|
// `Equal` isn't one native kind — the line/circle split below
|
|
// (design doc §5) needs the resolved entity types, not just the
|
|
// ConstraintKind, so it's handled by the caller instead of here.
|
|
ConstraintKind::Equal | ConstraintKind::Parallel | ConstraintKind::Distance
|
|
| ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter
|
|
| ConstraintKind::DistanceX | ConstraintKind::DistanceY => None,
|
|
// No native DWG representation exists at all: neither
|
|
// `AcExplicitConstr.h` (Distance/Angle/RadiusDiameter — no
|
|
// ArcLength-shaped class) nor `acadrust`'s `AssocConstraintNodeData`
|
|
// has anything for it. `constraint_node` below falls through to
|
|
// its own `_ => None` for this kind, same "can't persist to DWG,
|
|
// XRecord-only" gap the dependency-chain DXF omission already
|
|
// documents.
|
|
ConstraintKind::ArcLength => None,
|
|
}
|
|
}
|
|
|
|
/// `Diameter`/`DistanceX`/`DistanceY` reuse `Radius`'/`Distance`'s own DWG
|
|
/// class — real AutoCAD represents them as the same
|
|
/// `AcRadiusDiameterConstraint`/`AcDistanceConstraint` object with a
|
|
/// different `RadiusDiameterConstrType`/`DirectionType` mode byte (set in
|
|
/// `constraint_node` below), not a distinct native class.
|
|
const fn dimensional_class_name(kind: ConstraintKind) -> &'static str {
|
|
match kind {
|
|
ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY => "ACDISTANCECONSTRAINT",
|
|
ConstraintKind::Angle => "ACANGLECONSTRAINT",
|
|
ConstraintKind::Radius | ConstraintKind::Diameter => "ACRADIUSDIAMETERCONSTRAINT",
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
/// Builds one `AssocConstraintNode` for `constraint`, returning its node id
|
|
/// so the caller can track which ones still need a `value_dependency`
|
|
/// patched in once handles can be allocated (pass 2, `Allocator` — building
|
|
/// node *shape* only needs read access to resolve `Equal`'s line-vs-circle
|
|
/// split, `AssocGeomDependency`/`AssocValueDependency` handles need mutable
|
|
/// access to the same document, so this has to be two passes). `None` if
|
|
/// this constraint's refs don't resolve to nodes this pass supports
|
|
/// (mirrors `sketch_solve::build_constraint`'s own "skip, don't error"
|
|
/// contract for an unbuildable constraint).
|
|
fn constraint_node(
|
|
builder: &mut GroupBuilder,
|
|
document: &CadDocument,
|
|
constraint: &SketchConstraint,
|
|
needs_value_dependency: &mut Vec<(i32, Option<DrivingValue>)>,
|
|
) -> Option<i32> {
|
|
let refs: &[SketchRef] = &constraint.refs;
|
|
if let Some(class_name) = geometrical_class_name(constraint.kind) {
|
|
let target_refs: Vec<i32> = refs.iter().filter_map(|r| builder.ref_node(*r)).collect();
|
|
if target_refs.len() != refs.len() {
|
|
return None;
|
|
}
|
|
let owner_id = *target_refs.first()?;
|
|
let node_id = builder.alloc_node_id();
|
|
builder.push_node(
|
|
node_id,
|
|
class_name,
|
|
AssocConstraintNodeData::Geometrical { owner_id, is_implied: false, is_active: true },
|
|
);
|
|
for target in &target_refs {
|
|
builder.connect(node_id, *target);
|
|
}
|
|
return Some(node_id);
|
|
}
|
|
match constraint.kind {
|
|
ConstraintKind::Parallel => {
|
|
let [a, b] = refs else { return None };
|
|
let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?);
|
|
let node_id = builder.alloc_node_id();
|
|
builder.push_node(
|
|
node_id,
|
|
"ACPARALLELCONSTRAINT",
|
|
AssocConstraintNodeData::Parallel { owner_id: na, is_implied: false, is_active: true, datum_line_index: None },
|
|
);
|
|
builder.connect(node_id, na);
|
|
builder.connect(node_id, nb);
|
|
Some(node_id)
|
|
}
|
|
ConstraintKind::Equal => {
|
|
let [a, b] = refs else { return None };
|
|
let (na, nb) = (builder.ref_node(*a)?, builder.ref_node(*b)?);
|
|
let both_lines = matches!(document.get_entity(a.entity), Some(EntityType::Line(_)))
|
|
&& matches!(document.get_entity(b.entity), Some(EntityType::Line(_)));
|
|
let class_name = if both_lines { "ACEQUALLENGTHCONSTRAINT" } else { "ACEQUALRADIUSCONSTRAINT" };
|
|
let node_id = builder.alloc_node_id();
|
|
builder.push_node(
|
|
node_id,
|
|
class_name,
|
|
AssocConstraintNodeData::Geometrical { owner_id: na, is_implied: false, is_active: true },
|
|
);
|
|
builder.connect(node_id, na);
|
|
builder.connect(node_id, nb);
|
|
Some(node_id)
|
|
}
|
|
ConstraintKind::Distance | ConstraintKind::DistanceX | ConstraintKind::DistanceY
|
|
| ConstraintKind::Angle | ConstraintKind::Radius | ConstraintKind::Diameter => {
|
|
let target_refs: Vec<i32> = match constraint.kind {
|
|
ConstraintKind::Radius | ConstraintKind::Diameter => vec![builder.ref_node(*refs.first()?)?],
|
|
_ => {
|
|
let [a, b] = refs else { return None };
|
|
vec![builder.ref_node(*a)?, builder.ref_node(*b)?]
|
|
}
|
|
};
|
|
let owner_id = *target_refs.first()?;
|
|
let node_id = builder.alloc_node_id();
|
|
let data = match constraint.kind {
|
|
// Plain two-point distance: undirected (`kNotDirected`).
|
|
ConstraintKind::Distance => AssocConstraintNodeData::Distance {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
direction_type: 0,
|
|
distance: None,
|
|
},
|
|
// X-only/Y-only: `kFixedDirection` with the fixed unit
|
|
// vector the distance is measured along — AutoCAD's own
|
|
// representation of DistanceX/DistanceY, not a distinct
|
|
// constraint class.
|
|
ConstraintKind::DistanceX => AssocConstraintNodeData::Distance {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
direction_type: 1,
|
|
distance: Some(Vector3::new(1.0, 0.0, 0.0)),
|
|
},
|
|
ConstraintKind::DistanceY => AssocConstraintNodeData::Distance {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
direction_type: 1,
|
|
distance: Some(Vector3::new(0.0, 1.0, 0.0)),
|
|
},
|
|
ConstraintKind::Angle => AssocConstraintNodeData::Angle {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
sector_type: 0,
|
|
},
|
|
ConstraintKind::Radius => AssocConstraintNodeData::RadiusDiameter {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
mode: 0,
|
|
},
|
|
// `kCircleDiameter` — same class as Radius (`mode: 0` /
|
|
// `kCircleRadius`), different mode byte.
|
|
ConstraintKind::Diameter => AssocConstraintNodeData::RadiusDiameter {
|
|
owner_id,
|
|
is_implied: false,
|
|
is_active: true,
|
|
value_dependency: Handle::NULL,
|
|
dimension_dependency: Handle::NULL,
|
|
mode: 1,
|
|
},
|
|
_ => unreachable!(),
|
|
};
|
|
builder.push_node(node_id, dimensional_class_name(constraint.kind), data);
|
|
for target in &target_refs {
|
|
builder.connect(node_id, *target);
|
|
}
|
|
needs_value_dependency.push((node_id, constraint.driving_param.clone()));
|
|
Some(node_id)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Everything a scope's native graph needs beyond the group's own object:
|
|
/// the `AssocGeomDependency` and `AssocValueDependency`/`AssocVariable`
|
|
/// objects it references by handle, and the network/dictionary wiring.
|
|
struct Allocator<'a> {
|
|
document: &'a mut CadDocument,
|
|
/// One `AssocVariable` handle per distinct named parameter actually
|
|
/// referenced in this scope, created once and shared by every
|
|
/// constraint that references the same name (mirrors how AutoCAD's own
|
|
/// variables work — one object, many dependents).
|
|
variables: FxHashMap<String, Handle>,
|
|
}
|
|
|
|
impl Allocator<'_> {
|
|
fn insert_associative(&mut self, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) -> Handle {
|
|
let handle = self.document.allocate_handle();
|
|
self.insert_associative_at(handle, owner, dxf_name, cpp_class_name, data);
|
|
handle
|
|
}
|
|
|
|
/// Like [`Allocator::insert_associative`], but for a handle already
|
|
/// allocated up front — needed for the group/network pair, which
|
|
/// reference each other (`AssocAction::owning_network` /
|
|
/// `AssocNetwork::owned_actions`) and so must both have real handles
|
|
/// before either object is actually built.
|
|
fn insert_associative_at(&mut self, handle: Handle, owner: Handle, dxf_name: &str, cpp_class_name: &str, data: AssociativeData) {
|
|
let object = AssociativeObject {
|
|
handle,
|
|
owner,
|
|
dxf_name: dxf_name.to_string(),
|
|
cpp_class_name: cpp_class_name.to_string(),
|
|
data,
|
|
..Default::default()
|
|
};
|
|
self.document.objects.insert(handle, ObjectType::Associative(object));
|
|
}
|
|
|
|
fn geom_dependency(&mut self, group_handle: Handle, entity: Handle) -> Handle {
|
|
self.insert_associative(
|
|
group_handle,
|
|
"ACDBASSOCGEOMDEPENDENCY",
|
|
"AcDbAssocGeomDependency",
|
|
AssociativeData::GeomDependency(AssocGeomDependency {
|
|
dependency: AssocDependency {
|
|
class_version: 1,
|
|
is_read_dependency: true,
|
|
is_write_dependency: true,
|
|
is_attached_to_object: true,
|
|
dependent_on: entity,
|
|
..Default::default()
|
|
},
|
|
class_version: 1,
|
|
enabled: true,
|
|
// Whole-entity dependency, not a sub-entity — no persistent
|
|
// subentity class name needed (design doc §7.2: the real
|
|
// point/curve addressing lives on the constraint-node data,
|
|
// not here).
|
|
persistent_subent: AssocPersistentSubentId::default(),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// The `AssocVariable` handle for `name`, creating it once per scope.
|
|
/// `formula` is our own `ParameterTable` source string for this name —
|
|
/// `AcDbAssocVariable.expression` is exactly this shape (design doc §5).
|
|
fn variable(&mut self, group_handle: Handle, name: &str, formula: &str) -> Handle {
|
|
if let Some(&handle) = self.variables.get(name) {
|
|
return handle;
|
|
}
|
|
let handle = self.insert_associative(
|
|
group_handle,
|
|
"ACDBASSOCVARIABLE",
|
|
"AcDbAssocVariable",
|
|
AssociativeData::Variable(AssocVariable {
|
|
action: AssocAction::default(),
|
|
class_version: 1,
|
|
name: name.to_string(),
|
|
expression: formula.to_string(),
|
|
// Left empty deliberately: "If empty, the default evaluator
|
|
// for current acad version will be used" (Autodesk's own
|
|
// AcDbAssocVariable.h, design doc §5/§7.4) — not a gap.
|
|
evaluator: String::new(),
|
|
description: String::new(),
|
|
value: AssocEvalVariant::default(),
|
|
has_cached_value: false,
|
|
cached_value: String::new(),
|
|
flag: false,
|
|
reserved: 0,
|
|
}),
|
|
);
|
|
self.variables.insert(name.to_string(), handle);
|
|
handle
|
|
}
|
|
|
|
/// `variable_handle` is `Some` (from a prior call to [`Allocator::variable`])
|
|
/// when this driving value is `DrivingValue::Named` — `Handle::NULL`'s
|
|
/// meaning for `AssocDependency::dependent_on` (design doc §5: "`NULL`
|
|
/// for a bare literal, or an `AssocVariable`'s handle when the value is
|
|
/// driven by a named parameter") for a plain `DrivingValue::Literal`.
|
|
fn value_dependency(&mut self, group_handle: Handle, variable_handle: Option<Handle>, resolved: f64) -> Handle {
|
|
let value = AssocEvalVariant { code: 40, value: AssocEvalValue::Real(resolved) };
|
|
self.insert_associative(
|
|
group_handle,
|
|
"ACDBASSOCVALUEDEPENDENCY",
|
|
"AcDbAssocValueDependency",
|
|
AssociativeData::ValueDependency(AssocValueDependency {
|
|
dependency: AssocDependency {
|
|
class_version: 1,
|
|
is_read_dependency: true,
|
|
is_write_dependency: false,
|
|
dependent_on: variable_handle.unwrap_or(Handle::NULL),
|
|
..Default::default()
|
|
},
|
|
class_version: 1,
|
|
name: String::new(),
|
|
value,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Patches a geometry node's `geometry_dependency` field — only the
|
|
/// whole-geometry variants (`BoundedLine`/`Circle`/`Arc`) carry one;
|
|
/// `ImplicitPoint` nodes stay `Handle::NULL` (design doc §7.2, confirmed
|
|
/// from Autodesk's own `AcConstrainedImplicitPoint` docs: it does not hold
|
|
/// its own `AssocGeomDependency`, only `curve_id` pointing at the geometry
|
|
/// node that does).
|
|
fn set_geometry_dependency(data: &mut AssocConstraintNodeData, handle: Handle) {
|
|
match data {
|
|
AssocConstraintNodeData::BoundedLine { geometry_dependency, .. }
|
|
| AssocConstraintNodeData::Circle { geometry_dependency, .. }
|
|
| AssocConstraintNodeData::Arc { geometry_dependency, .. } => {
|
|
*geometry_dependency = handle;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Patches a dimensional-constraint node's `value_dependency` field
|
|
/// (`Distance`/`Angle`/`RadiusDiameter` — the three kinds
|
|
/// [`constraint_node`] defers to `needs_value_dependency`).
|
|
fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) {
|
|
match data {
|
|
AssocConstraintNodeData::Distance { value_dependency, .. }
|
|
| AssocConstraintNodeData::Angle { value_dependency, .. }
|
|
| AssocConstraintNodeData::RadiusDiameter { value_dependency, .. } => {
|
|
*value_dependency = handle;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// The DWG/DXF CLASSES-table entries this module's five `AssociativeObject`
|
|
/// kinds need before they can be written with a real class number.
|
|
/// Confirmed by reading the actual writer: `write_associative_object`
|
|
/// resolves the class number via
|
|
/// `document.classes.get_by_name(&object.dxf_name)`
|
|
/// (`io/dwg/dwg_stream_writers/object_writer/associative.rs:700-709`), and
|
|
/// 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
|
|
/// never declares them, so a reload can't dispatch the type-500 objects back
|
|
/// to `read_associative_object` either (`dwg_document_builder.rs`'s object
|
|
/// dispatcher keys off exactly that class-number -> name mapping). A fresh
|
|
/// `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) {
|
|
for (dxf_name, cpp_class_name) in ASSOC_CLASSES {
|
|
if document.classes.get_by_name(dxf_name).is_some() {
|
|
continue;
|
|
}
|
|
document.classes.add_or_update(acadrust::classes::DxfClass {
|
|
dxf_name: dxf_name.to_string(),
|
|
cpp_class_name: cpp_class_name.to_string(),
|
|
application_name: "ObjectDBX Classes".to_string(),
|
|
proxy_flags: acadrust::classes::ProxyFlags(
|
|
acadrust::classes::ProxyFlags::ERASE_ALLOWED.0
|
|
| acadrust::classes::ProxyFlags::CLONING_ALLOWED.0
|
|
| acadrust::classes::ProxyFlags::DISABLES_PROXY_WARNING_DIALOG.0,
|
|
),
|
|
instance_count: 0,
|
|
was_zombie: false,
|
|
is_an_entity: false,
|
|
class_number: 0,
|
|
// 499 = "object" (non-entity) — every class here is one.
|
|
item_class_id: 0x1F3,
|
|
dwg_version: 0,
|
|
maintenance_version: 0,
|
|
unknown1: 0,
|
|
unknown2: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Ensures `owner`'s extension dictionary exists, returning its handle.
|
|
/// `acadrust::CadDocument` only exposes creating one through
|
|
/// [`CadDocument::ensure_xrecord`] — the `xdic_by_handle` side map a
|
|
/// Dictionary-owned object would otherwise need is private to that crate
|
|
/// (design doc's documented reason this module anchors at a real entity
|
|
/// owner instead of the top-level NOD). So a throwaway XRecord is 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 {
|
|
if let Some(handle) = document.extension_dictionary_handle(owner) {
|
|
return handle;
|
|
}
|
|
const BOOTSTRAP_KEY: &str = "OCS_DWG_NATIVE_BOOTSTRAP";
|
|
document.ensure_xrecord(owner, BOOTSTRAP_KEY);
|
|
let dictionary_handle = document
|
|
.extension_dictionary_handle(owner)
|
|
.expect("ensure_xrecord just created the extension dictionary");
|
|
remove_dictionary_entry(document, dictionary_handle, BOOTSTRAP_KEY);
|
|
dictionary_handle
|
|
}
|
|
|
|
/// Removes a named entry from `dictionary_handle`, and recursively removes
|
|
/// 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) {
|
|
let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else {
|
|
return;
|
|
};
|
|
let Some(index) = dictionary.entries.iter().position(|(name, _)| name.eq_ignore_ascii_case(key)) else {
|
|
return;
|
|
};
|
|
let (_, target) = dictionary.entries.remove(index);
|
|
remove_owned_recursive(document, target);
|
|
}
|
|
|
|
fn set_dictionary_entry(document: &mut CadDocument, dictionary_handle: Handle, key: &str, target: Handle) {
|
|
let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary_handle) else {
|
|
return;
|
|
};
|
|
if let Some((_, handle)) = dictionary.entries.iter_mut().find(|(name, _)| name.eq_ignore_ascii_case(key)) {
|
|
*handle = target;
|
|
} else {
|
|
dictionary.add_entry(key, target);
|
|
}
|
|
}
|
|
|
|
/// Removes `root` and every object transitively owned by it (design doc §6:
|
|
/// this module rebuilds a scope's whole native graph on every save, so the
|
|
/// previous save's objects must be fully torn down first — otherwise a
|
|
/// resave accumulates orphaned `AssocGeomDependency`/`AssocValueDependency`/
|
|
/// `AssocVariable` objects forever instead of replacing them).
|
|
fn remove_owned_recursive(document: &mut CadDocument, root: Handle) {
|
|
if root.is_null() {
|
|
return;
|
|
}
|
|
let children: Vec<Handle> =
|
|
document.objects.keys().copied().filter(|&h| document.object_owner(h) == Some(root)).collect();
|
|
for child in children {
|
|
remove_owned_recursive(document, child);
|
|
}
|
|
document.objects.remove(&root);
|
|
}
|
|
|
|
/// Whether `owner`'s scope already carries a materialized native graph —
|
|
/// from an earlier save with the setting on, or from a file that came from
|
|
/// real AutoCAD/BricsCAD. 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 {
|
|
let Some(dictionary_handle) = document.extension_dictionary_handle(owner) else {
|
|
return false;
|
|
};
|
|
let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dictionary_handle) else {
|
|
return false;
|
|
};
|
|
dictionary.get(NETWORK_DICTIONARY_KEY).is_some()
|
|
}
|
|
|
|
/// One scope's worth of
|
|
/// [`Scene::materialize_dwg_native_constraints_for_save`] — see that
|
|
/// method's doc comment for the save-time contract this implements.
|
|
fn materialize_scope(document: &mut CadDocument, owner: Handle, set: &SketchConstraintSet, parameters: &ParameterTable) {
|
|
let dictionary_handle = ensure_extension_dictionary(document, owner);
|
|
remove_dictionary_entry(document, dictionary_handle, NETWORK_DICTIONARY_KEY);
|
|
|
|
// Pass 1 (read-only): constraint-node shapes.
|
|
let mut needs_value_dependency = Vec::new();
|
|
let (mut nodes, entities) = {
|
|
let mut builder = GroupBuilder::new(document);
|
|
for constraint in &set.constraints {
|
|
if !constraint.enabled {
|
|
continue;
|
|
}
|
|
constraint_node(&mut builder, document, constraint, &mut needs_value_dependency);
|
|
}
|
|
let GroupBuilder { nodes, entities, .. } = builder;
|
|
(nodes, entities)
|
|
};
|
|
if nodes.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Root pseudo-node (`nodes[0]`): confirmed mandatory by reading the DWG
|
|
// 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(
|
|
0,
|
|
AssocConstraintNode {
|
|
node_id: 0,
|
|
status: 1,
|
|
connections: all_ids,
|
|
class_name: String::new(),
|
|
registry_flag: false,
|
|
data: AssocConstraintNodeData::None,
|
|
},
|
|
);
|
|
|
|
// Pass 2 (mutable): allocate handles for everything the node shapes
|
|
// above still reference as `Handle::NULL`. The group and network
|
|
// reference each other, so both handles are reserved up front.
|
|
let group_handle = document.allocate_handle();
|
|
let network_handle = document.allocate_handle();
|
|
let mut allocator = Allocator { document, variables: FxHashMap::default() };
|
|
|
|
for (entity_handle, entity_nodes) in &entities {
|
|
if entity_nodes.geometry_node_id == 0 {
|
|
continue;
|
|
}
|
|
let dep_handle = allocator.geom_dependency(group_handle, *entity_handle);
|
|
if let Some(node) = nodes.iter_mut().find(|n| n.node_id == entity_nodes.geometry_node_id) {
|
|
set_geometry_dependency(&mut node.data, dep_handle);
|
|
}
|
|
}
|
|
|
|
for (node_id, driving) in needs_value_dependency {
|
|
let Some(driving) = driving else { continue };
|
|
let Ok(resolved) = driving.resolve(parameters) else { continue };
|
|
let variable_handle = match &driving {
|
|
DrivingValue::Named(name) => {
|
|
let formula = parameters.get(name).map(|p| p.source.clone()).unwrap_or_default();
|
|
Some(allocator.variable(group_handle, name, &formula))
|
|
}
|
|
DrivingValue::Literal(_) => None,
|
|
};
|
|
let dep_handle = allocator.value_dependency(group_handle, variable_handle, resolved);
|
|
if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) {
|
|
set_value_dependency(&mut node.data, dep_handle);
|
|
}
|
|
}
|
|
|
|
let group = Assoc2dConstraintGroup {
|
|
action: AssocAction { owning_network: network_handle, ..Default::default() },
|
|
version: 1,
|
|
flag: true,
|
|
// Identity XY plane at the world origin: every entity this module
|
|
// builds constraint nodes for already lives in world XY (design doc
|
|
// §4 — a rotated/offset sketch plane isn't modeled yet).
|
|
work_plane: [Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y],
|
|
dependency: Handle::NULL,
|
|
actions: Vec::new(),
|
|
nodes,
|
|
};
|
|
allocator.insert_associative_at(
|
|
group_handle,
|
|
network_handle,
|
|
"ASSOC2DCONSTRAINTGROUP",
|
|
"AcDbAssoc2dConstraintGroup",
|
|
AssociativeData::ConstraintGroup(group),
|
|
);
|
|
|
|
let network = AssocNetwork {
|
|
action: AssocAction::default(),
|
|
network_version: 1,
|
|
network_action_index: 0,
|
|
actions: Vec::new(),
|
|
owned_actions: vec![group_handle],
|
|
};
|
|
allocator.insert_associative_at(
|
|
network_handle,
|
|
dictionary_handle,
|
|
"ASSOCNETWORK",
|
|
"AcDbAssocNetwork",
|
|
AssociativeData::Network(network),
|
|
);
|
|
|
|
set_dictionary_entry(allocator.document, dictionary_handle, NETWORK_DICTIONARY_KEY, network_handle);
|
|
}
|
|
|
|
impl Scene {
|
|
/// Rebuilds every sketch scope's native `AssocNetwork`/
|
|
/// `Assoc2dConstraintGroup` graph from `self.sketch_constraints`,
|
|
/// replacing whatever this module wrote on a previous save — the same
|
|
/// "materialize right before save, from scratch every time" convention
|
|
/// `sketch_persist.rs`'s `materialize_sketch_constraints_for_save` uses,
|
|
/// called alongside it, not instead of it (design doc §6: additive).
|
|
///
|
|
/// `enabled` is the app's `write_dwg_native_constraints` Options toggle
|
|
/// (off by default — this graph adds file weight every save, whether or
|
|
/// not AutoCAD/BricsCAD interop is wanted). It only gates *creating* the
|
|
/// graph in a scope that doesn't have one yet: a scope that already
|
|
/// carries one — from an earlier save with the setting on, or from a
|
|
/// file that came from real AutoCAD — keeps getting it rebuilt in sync
|
|
/// regardless of the current setting ([`has_native_network`]), so
|
|
/// 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) {
|
|
let mut classes_registered = false;
|
|
for index in 0..self.sketch_constraints.len() {
|
|
let set = self.sketch_constraints[index].clone();
|
|
let owner = set.scope.owner_handle(&self.document);
|
|
if owner.is_null() {
|
|
continue;
|
|
}
|
|
if !enabled && !has_native_network(&self.document, owner) {
|
|
continue;
|
|
}
|
|
if !classes_registered {
|
|
ensure_associative_classes_registered(&mut self.document);
|
|
classes_registered = true;
|
|
}
|
|
materialize_scope(&mut self.document, owner, &set, &self.named_parameters);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::sketch_constraints::SketchScope;
|
|
use super::*;
|
|
use acadrust::entities::{Arc, Circle, Line};
|
|
|
|
fn line_entity(scene: &mut Scene, start: (f64, f64), end: (f64, f64)) -> Handle {
|
|
scene.add_entity(EntityType::Line(Line::from_points(
|
|
Vector3::new(start.0, start.1, 0.0),
|
|
Vector3::new(end.0, end.1, 0.0),
|
|
)))
|
|
}
|
|
|
|
fn circle_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle {
|
|
scene.add_entity(EntityType::Circle(Circle::from_center_radius(Vector3::new(center.0, center.1, 0.0), radius)))
|
|
}
|
|
|
|
fn arc_entity(scene: &mut Scene, center: (f64, f64), radius: f64) -> Handle {
|
|
scene.add_entity(EntityType::Arc(Arc::from_center_radius_angles(
|
|
Vector3::new(center.0, center.1, 0.0),
|
|
radius,
|
|
0.0,
|
|
std::f64::consts::PI,
|
|
)))
|
|
}
|
|
|
|
/// Digs a scope's materialized native graph back out of `document`:
|
|
/// `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.extension_dictionary_handle(owner).expect("extension dictionary should exist");
|
|
let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&dict) else {
|
|
panic!("expected owner's extension dictionary object to exist");
|
|
};
|
|
let network_handle =
|
|
dictionary.get(NETWORK_DICTIONARY_KEY).expect("ACAD_ASSOCNETWORK entry should exist");
|
|
let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::Network(network), .. })) =
|
|
document.objects.get(&network_handle)
|
|
else {
|
|
panic!("expected an AssocNetwork object at the ACAD_ASSOCNETWORK handle");
|
|
};
|
|
let group_handle = *network.owned_actions.first().expect("network should own the constraint group");
|
|
let Some(ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(group), .. })) =
|
|
document.objects.get(&group_handle)
|
|
else {
|
|
panic!("expected an Assoc2dConstraintGroup object at the network's owned action handle");
|
|
};
|
|
group
|
|
}
|
|
|
|
#[test]
|
|
fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
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));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Horizontal,
|
|
vec![SketchRef::whole(a)],
|
|
None,
|
|
);
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Vertical,
|
|
vec![SketchRef::whole(b)],
|
|
None,
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
// Sanity before the round trip even happens: root + 2 geometry
|
|
// nodes + 2 constraint nodes.
|
|
let before = native_group(&scene.document, owner);
|
|
assert_eq!(before.nodes.len(), 5, "expected root + 2 geometry + 2 constraint nodes, got {:#?}", before.nodes);
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded = crate::io::load_bytes(&format!("dwg_native_poc.{ext}"), bytes)
|
|
.unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
assert!(
|
|
class_names.contains(&"ACHORIZONTALCONSTRAINT"),
|
|
"{ext}: missing horizontal constraint node, got {class_names:?}"
|
|
);
|
|
assert!(
|
|
class_names.contains(&"ACVERTICALCONSTRAINT"),
|
|
"{ext}: missing vertical constraint node, got {class_names:?}"
|
|
);
|
|
assert_eq!(
|
|
class_names.iter().filter(|&&n| n == "ACCONSTRAINEDBOUNDEDLINE").count(),
|
|
2,
|
|
"{ext}: expected both lines' geometry nodes to survive, got {class_names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn distance_with_named_parameter_scene() -> (Scene, Handle) {
|
|
let mut scene = Scene::new();
|
|
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
let b = line_entity(&mut scene, (20.0, 0.0), (30.0, 0.0));
|
|
scene.named_parameters.set("gap", "5").expect("defining the parameter should succeed");
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Distance,
|
|
vec![SketchRef::point(a, 1), SketchRef::point(b, 0)],
|
|
Some(DrivingValue::Named("gap".to_string())),
|
|
);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
(scene, owner)
|
|
}
|
|
|
|
fn has_variable_named(document: &CadDocument, name: &str) -> bool {
|
|
document.objects.values().any(|obj| {
|
|
matches!(
|
|
obj,
|
|
ObjectType::Associative(AssociativeObject { data: AssociativeData::Variable(v), .. })
|
|
if v.name == name
|
|
)
|
|
})
|
|
}
|
|
|
|
/// DWG is the format this module's dependency chain (`AssocGeomDependency`
|
|
/// / `AssocValueDependency` / `AssocVariable`) actually survives a save
|
|
/// through — see [`a_dxf_save_keeps_only_the_constraint_group_shell`] for
|
|
/// the DXF side of this same scene and why it's different, not a bug here.
|
|
#[test]
|
|
fn a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable() {
|
|
let (mut scene, owner) = distance_with_named_parameter_scene();
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
assert!(has_variable_named(&scene.document, "gap"), "expected an AssocVariable BEFORE serialization");
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, "dwg", scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to dwg: {e}"));
|
|
let reloaded = crate::io::load_bytes("dwg_native_named_param.dwg", bytes)
|
|
.unwrap_or_else(|e| panic!("reload dwg: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
assert!(
|
|
group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"),
|
|
"expected an ACDISTANCECONSTRAINT node, got {:?}",
|
|
group.nodes.iter().map(|n| &n.class_name).collect::<Vec<_>>()
|
|
);
|
|
assert!(has_variable_named(&reloaded, "gap"), "expected the AssocVariable \"gap\" to survive a DWG round trip");
|
|
}
|
|
|
|
/// Confirmed by reading the actual DXF writer
|
|
/// (`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]
|
|
fn a_dxf_save_keeps_only_the_constraint_group_shell() {
|
|
let (mut scene, owner) = distance_with_named_parameter_scene();
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, "dxf", scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to dxf: {e}"));
|
|
let reloaded = crate::io::load_bytes("dwg_native_named_param.dxf", bytes)
|
|
.unwrap_or_else(|e| panic!("reload dxf: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
assert!(
|
|
group.nodes.iter().any(|n| n.class_name == "ACDISTANCECONSTRAINT"),
|
|
"expected the ACDISTANCECONSTRAINT node's shell to survive, got {:?}",
|
|
group.nodes.iter().map(|n| &n.class_name).collect::<Vec<_>>()
|
|
);
|
|
assert!(
|
|
!has_variable_named(&reloaded, "gap"),
|
|
"acadrust's DXF writer should not carry AssocVariable — if this now passes, upstream has fixed \
|
|
is_unrestorable_assoc_object and materialize_scope's doc comments/design doc should be updated"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resaving_replaces_rather_than_accumulates_native_objects() {
|
|
let mut scene = Scene::new();
|
|
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Horizontal,
|
|
vec![SketchRef::whole(a)],
|
|
None,
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let first_count = scene.document.objects.len();
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let second_count = scene.document.objects.len();
|
|
assert_eq!(first_count, second_count, "a resave with the same constraints must not accumulate new objects");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_constraint_set_leaves_no_native_network() {
|
|
let mut scene = Scene::new();
|
|
let _ = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
let dict = scene.document.extension_dictionary_handle(owner);
|
|
if let Some(dict) = dict {
|
|
if let Some(ObjectType::Dictionary(dictionary)) = scene.document.objects.get(&dict) {
|
|
assert!(
|
|
dictionary.get(NETWORK_DICTIONARY_KEY).is_none(),
|
|
"an unconstrained scope should not materialize a native network"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn model_space_has_native_network(scene: &Scene) -> bool {
|
|
has_native_network(&scene.document, scene.document.header.model_space_block_handle)
|
|
}
|
|
|
|
#[test]
|
|
fn the_setting_off_skips_a_scope_with_no_existing_native_graph() {
|
|
let mut scene = Scene::new();
|
|
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Horizontal,
|
|
vec![SketchRef::whole(a)],
|
|
None,
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(false);
|
|
assert!(
|
|
!model_space_has_native_network(&scene),
|
|
"a scope with no prior native graph should stay untouched while the setting is off"
|
|
);
|
|
}
|
|
|
|
/// The "sync-if-present, create-only-if-enabled" rule from
|
|
/// `Scene::materialize_dwg_native_constraints_for_save`'s doc comment:
|
|
/// once a scope has the native graph (here, from an earlier save with
|
|
/// the setting on), turning the setting off must not freeze that graph
|
|
/// out of date — it should keep tracking the current constraints.
|
|
#[test]
|
|
fn the_setting_off_still_resyncs_a_scope_that_already_has_a_native_graph() {
|
|
let mut scene = Scene::new();
|
|
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Horizontal,
|
|
vec![SketchRef::whole(a)],
|
|
None,
|
|
);
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
assert!(model_space_has_native_network(&scene), "sanity: the graph should exist after the first, enabled save");
|
|
|
|
let b = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Vertical,
|
|
vec![SketchRef::whole(b)],
|
|
None,
|
|
);
|
|
scene.materialize_dwg_native_constraints_for_save(false);
|
|
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
let group = native_group(&scene.document, owner);
|
|
assert!(
|
|
group.nodes.iter().any(|n| n.class_name == "ACVERTICALCONSTRAINT"),
|
|
"the second, disabled-setting save should still pick up the new Vertical constraint, got {:?}",
|
|
group.nodes.iter().map(|n| &n.class_name).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
/// The other half of the same rule: disabling the setting after a scope
|
|
/// already has the graph must not make that graph disappear either —
|
|
/// only remove it by clearing the scope's constraints (existing
|
|
/// `an_empty_constraint_set_leaves_no_native_network` behavior), not by
|
|
/// toggling the setting off.
|
|
#[test]
|
|
fn the_setting_off_does_not_remove_an_existing_native_graph() {
|
|
let mut scene = Scene::new();
|
|
let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Horizontal,
|
|
vec![SketchRef::whole(a)],
|
|
None,
|
|
);
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
scene.materialize_dwg_native_constraints_for_save(false);
|
|
assert!(
|
|
model_space_has_native_network(&scene),
|
|
"an existing native graph must survive a disabled-setting resave, not be silently dropped"
|
|
);
|
|
}
|
|
|
|
/// These `ConstraintKind`s all reuse the same generic
|
|
/// `Geometrical`-shaped node path `geometrical_class_name` drives (see
|
|
/// its own doc comment) — one representative mix of ref shapes (2 whole
|
|
/// entities, an asymmetric point+whole pair, and a 3-ref case) is
|
|
/// enough to confirm that path handles them all, without one dedicated
|
|
/// test per kind.
|
|
#[test]
|
|
fn new_constraint_kinds_round_trip_with_their_own_dwg_native_class_names() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let circle_a = circle_entity(&mut scene, (0.0, 0.0), 3.0);
|
|
let circle_b = circle_entity(&mut scene, (5.0, 5.0), 1.0);
|
|
let line_a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
let line_b = line_entity(&mut scene, (3.0, 4.0), (7.0, 6.0));
|
|
let marker = line_entity(&mut scene, (20.0, 20.0), (21.0, 21.0));
|
|
let axis = line_entity(&mut scene, (0.0, 0.0), (0.0, 10.0));
|
|
|
|
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
set.add(ConstraintKind::Concentric, vec![SketchRef::center(circle_a), SketchRef::center(circle_b)], None);
|
|
set.add(ConstraintKind::Colinear, vec![SketchRef::whole(line_a), SketchRef::whole(line_b)], None);
|
|
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(line_a)], None);
|
|
set.add(ConstraintKind::CenterPoint, vec![SketchRef::point(marker, 0), SketchRef::center(circle_a)], None);
|
|
set.add(ConstraintKind::Midpoint, vec![SketchRef::point(marker, 1), SketchRef::whole(line_a)], None);
|
|
set.add(ConstraintKind::PointOnCurve, vec![SketchRef::point(marker, 0), SketchRef::whole(circle_a)], None);
|
|
set.add(
|
|
ConstraintKind::EqualDistance,
|
|
vec![SketchRef::point(line_b, 0), SketchRef::point(line_b, 1), SketchRef::point(line_a, 0), SketchRef::point(line_a, 1)],
|
|
None,
|
|
);
|
|
set.add(
|
|
ConstraintKind::Symmetric,
|
|
vec![SketchRef::center(circle_a), SketchRef::center(circle_b), SketchRef::whole(axis)],
|
|
None,
|
|
);
|
|
set.add(ConstraintKind::Normal, vec![SketchRef::whole(circle_a), SketchRef::whole(line_a)], None);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded = crate::io::load_bytes(&format!("new_kinds.{ext}"), bytes)
|
|
.unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
|
|
for expected in [
|
|
"ACCONCENTRICCONSTRAINT",
|
|
"ACCOLINEARCONSTRAINT",
|
|
"ACFIXEDCONSTRAINT",
|
|
"ACCENTERPOINTCONSTRAINT",
|
|
"ACMIDPOINTCONSTRAINT",
|
|
"ACPOINTCURVECONSTRAINT",
|
|
"ACEQUALDISTANCECONSTRAINT",
|
|
"ACSYMMETRICCONSTRAINT",
|
|
"ACNORMALCONSTRAINT",
|
|
] {
|
|
assert!(class_names.contains(&expected), "{ext}: missing {expected} node, got {class_names:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Constraint-parity Phase 1: an Arc registers in the solver as its own
|
|
/// center/radius (`sketch_solve.rs`'s `EntityGeom::Circle` reuse), and
|
|
/// this module already had `ACCONSTRAINEDARC` geometry-dependency
|
|
/// support in place before that. This confirms the two sides actually
|
|
/// meet: a Concentric/Tangent/Radius constraint referencing an arc
|
|
/// still gets its expected class names AND its geometry node is the
|
|
/// arc-specific one, through a real DWG/DXF byte round trip — not just
|
|
/// the in-memory graph this test module builds directly.
|
|
#[test]
|
|
fn constraints_referencing_an_arc_round_trip_with_the_arc_geometry_node() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0);
|
|
let circle = circle_entity(&mut scene, (10.0, -6.0), 2.0);
|
|
|
|
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
set.add(ConstraintKind::Concentric, vec![SketchRef::center(arc), SketchRef::center(circle)], None);
|
|
set.add(
|
|
ConstraintKind::Radius,
|
|
vec![SketchRef::whole(arc)],
|
|
Some(crate::scene::named_parameters::DrivingValue::Literal(9.0)),
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded = crate::io::load_bytes(&format!("arc_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
assert!(class_names.contains(&"ACCONCENTRICCONSTRAINT"), "{ext}: missing ACCONCENTRICCONSTRAINT, got {class_names:?}");
|
|
assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}");
|
|
assert!(
|
|
group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"),
|
|
"{ext}: the arc should have its own geometry node, got {class_names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Constraint-parity Phase 4b: a Coincident constraint referencing an
|
|
/// arc's actual *endpoint* (marker 0, not its center via `-3` or its
|
|
/// whole curve) round-trips correctly too — `SketchRef`'s marker is
|
|
/// serialized generically regardless of what entity type or point it
|
|
/// addresses, so this needs no dedicated DWG-side wiring beyond what
|
|
/// already existed; this test is here to actually confirm that rather
|
|
/// than assume it.
|
|
#[test]
|
|
fn a_coincident_constraint_on_an_arcs_endpoint_round_trips() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0);
|
|
let line = line_entity(&mut scene, (20.0, 20.0), (21.0, 20.0));
|
|
|
|
scene.sketch_constraint_set_mut(SketchScope::ModelSpace).add(
|
|
ConstraintKind::Coincident,
|
|
vec![SketchRef::point(arc, 0), SketchRef::point(line, 0)],
|
|
None,
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded =
|
|
crate::io::load_bytes(&format!("arc_endpoint_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
assert!(class_names.contains(&"ACPOINTCOINCIDENCECONSTRAINT"), "{ext}: missing ACPOINTCOINCIDENCECONSTRAINT, got {class_names:?}");
|
|
assert!(
|
|
group.nodes.iter().any(|n| n.class_name == "ACCONSTRAINEDARC"),
|
|
"{ext}: the arc should still have its own geometry node, got {class_names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Constraint-parity Phase 5: an ellipse isn't a supported geometry
|
|
/// node type in `geometry_node` yet (see its own doc comment for why),
|
|
/// so a Concentric constraint referencing one degrades to "not in the
|
|
/// native graph" — the same "skip, don't error" contract every other
|
|
/// unbuildable native-constraint case gets — while the other
|
|
/// constraint in the same scope (Fixed, on a plain circle) still
|
|
/// persists normally, and the ellipse constraint itself still survives
|
|
/// in this app's own XRecord format.
|
|
#[test]
|
|
fn a_constraint_referencing_an_ellipse_is_absent_from_the_native_graph_but_not_from_xrecord() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let ellipse = scene.add_entity(EntityType::Ellipse(acadrust::entities::Ellipse::from_center_axes(
|
|
Vector3::new(0.0, 0.0, 0.0),
|
|
Vector3::new(4.0, 0.0, 0.0),
|
|
0.5,
|
|
)));
|
|
let circle = circle_entity(&mut scene, (10.0, 10.0), 2.0);
|
|
|
|
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
set.add(ConstraintKind::Concentric, vec![SketchRef::center(ellipse), SketchRef::center(circle)], None);
|
|
set.add(ConstraintKind::Fixed, vec![SketchRef::whole(circle)], None);
|
|
|
|
scene.materialize_sketch_constraints_for_save();
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded_doc =
|
|
crate::io::load_bytes(&format!("ellipse_ref.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded_doc, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
assert!(class_names.contains(&"ACFIXEDCONSTRAINT"), "{ext}: the other constraint should still persist, got {class_names:?}");
|
|
assert!(
|
|
!class_names.contains(&"ACCONCENTRICCONSTRAINT"),
|
|
"{ext}: the ellipse-referencing Concentric constraint has no supported geometry node and must not appear, got {class_names:?}"
|
|
);
|
|
|
|
let mut reloaded_scene = Scene::new();
|
|
reloaded_scene.document = reloaded_doc;
|
|
reloaded_scene.load_sketch_constraints_from_document();
|
|
let restored = reloaded_scene
|
|
.sketch_constraint_set(SketchScope::ModelSpace)
|
|
.unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip"));
|
|
let kinds: Vec<ConstraintKind> = restored.constraints.iter().map(|c| c.kind).collect();
|
|
assert!(kinds.contains(&ConstraintKind::Concentric), "{ext}: the ellipse constraint should still round-trip via XRecord, got {kinds:?}");
|
|
}
|
|
}
|
|
|
|
/// Constraint-parity Phase 4a: `ArcLength` has no ObjectARX class at
|
|
/// all (confirmed against `AcExplicitConstr.h`) — this asserts the
|
|
/// degradation is exactly as documented: the native DWG/DXF graph
|
|
/// gets every *other* constraint on the arc (Radius, here) but no
|
|
/// ArcLength-shaped node, while the app's own constraint set (what
|
|
/// XRecord persistence actually serializes, per `sketch_persist.rs`'s
|
|
/// fully-generic encode/decode) still carries it untouched.
|
|
#[test]
|
|
fn arc_length_has_no_native_class_but_survives_in_the_apps_own_constraint_set() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let arc = arc_entity(&mut scene, (0.0, 0.0), 4.0);
|
|
|
|
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
set.add(ConstraintKind::Radius, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(4.0)));
|
|
set.add(ConstraintKind::ArcLength, vec![SketchRef::whole(arc)], Some(DrivingValue::Literal(6.0)));
|
|
|
|
// Both materializers, "called alongside, not instead of" each
|
|
// other (`materialize_dwg_native_constraints_for_save`'s own
|
|
// doc comment) — the native graph and this app's own XRecord
|
|
// format are independent, additive persistence paths.
|
|
scene.materialize_sketch_constraints_for_save();
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded_doc =
|
|
crate::io::load_bytes(&format!("arc_length.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded_doc, owner);
|
|
let class_names: Vec<&str> = group.nodes.iter().map(|n| n.class_name.as_str()).collect();
|
|
assert!(class_names.contains(&"ACRADIUSDIAMETERCONSTRAINT"), "{ext}: missing ACRADIUSDIAMETERCONSTRAINT, got {class_names:?}");
|
|
assert!(
|
|
!class_names.iter().any(|n| n.contains("ARCLENGTH")),
|
|
"{ext}: ArcLength has no native DWG class and must not appear here, got {class_names:?}"
|
|
);
|
|
|
|
// Still present in this app's own (XRecord-backed) constraint
|
|
// set — reload into a fresh `Scene` and read it back the same
|
|
// way `sketch_persist.rs`'s own round-trip tests do.
|
|
let mut reloaded_scene = Scene::new();
|
|
reloaded_scene.document = reloaded_doc;
|
|
reloaded_scene.load_sketch_constraints_from_document();
|
|
let restored = reloaded_scene
|
|
.sketch_constraint_set(SketchScope::ModelSpace)
|
|
.unwrap_or_else(|| panic!("{ext}: no ModelSpace constraint set survived the round trip"));
|
|
let kinds: Vec<ConstraintKind> = restored.constraints.iter().map(|c| c.kind).collect();
|
|
assert!(kinds.contains(&ConstraintKind::ArcLength), "{ext}: ArcLength should still round-trip via XRecord, got {kinds:?}");
|
|
}
|
|
}
|
|
|
|
/// Constraint-parity Phase 2: `Diameter`/`DistanceX`/`DistanceY` reuse
|
|
/// `Radius`'/`Distance`'s own DWG class with a different
|
|
/// `RadiusDiameterConstrType`/`DirectionType` mode byte, matching real
|
|
/// AutoCAD's representation (this module's own research, folded into
|
|
/// `dimensional_class_name`'s doc comment). Confirms that byte actually
|
|
/// survives a real DWG/DXF write+read, not just the in-memory node this
|
|
/// module built.
|
|
#[test]
|
|
fn diameter_and_directed_distance_constraints_round_trip_with_their_mode_byte() {
|
|
for ext in ["dxf", "dwg"] {
|
|
let mut scene = Scene::new();
|
|
let circle = circle_entity(&mut scene, (0.0, 0.0), 3.0);
|
|
let line = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0));
|
|
|
|
let set = scene.sketch_constraint_set_mut(SketchScope::ModelSpace);
|
|
set.add(ConstraintKind::Diameter, vec![SketchRef::whole(circle)], Some(DrivingValue::Literal(16.0)));
|
|
set.add(
|
|
ConstraintKind::DistanceX,
|
|
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
|
|
Some(DrivingValue::Literal(10.0)),
|
|
);
|
|
set.add(
|
|
ConstraintKind::DistanceY,
|
|
vec![SketchRef::point(line, 0), SketchRef::point(line, 1)],
|
|
Some(DrivingValue::Literal(3.0)),
|
|
);
|
|
|
|
scene.materialize_dwg_native_constraints_for_save(true);
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext}: {e}"));
|
|
let reloaded =
|
|
crate::io::load_bytes(&format!("diameter_directed.{ext}"), bytes).unwrap_or_else(|e| panic!("reload {ext}: {e}"));
|
|
|
|
let group = native_group(&reloaded, owner);
|
|
let radius_diameter_nodes: Vec<_> = group
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|n| match &n.data {
|
|
AssocConstraintNodeData::RadiusDiameter { mode, .. } => Some(*mode),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(radius_diameter_nodes, vec![1], "{ext}: Diameter should round-trip as mode=1 (kCircleDiameter), got {radius_diameter_nodes:?}");
|
|
|
|
let distance_nodes: Vec<_> = group
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|n| match &n.data {
|
|
AssocConstraintNodeData::Distance { direction_type, distance, .. } => Some((*direction_type, *distance)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(distance_nodes.len(), 2, "{ext}: expected DistanceX and DistanceY nodes, got {distance_nodes:?}");
|
|
assert!(
|
|
distance_nodes.contains(&(1, Some(Vector3::new(1.0, 0.0, 0.0)))),
|
|
"{ext}: DistanceX should round-trip as direction_type=1 with a (1,0,0) direction, got {distance_nodes:?}"
|
|
);
|
|
assert!(
|
|
distance_nodes.contains(&(1, Some(Vector3::new(0.0, 1.0, 0.0)))),
|
|
"{ext}: DistanceY should round-trip as direction_type=1 with a (0,1,0) direction, got {distance_nodes:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|