Files
OneCAD-Draft/docs/dwg_constraint_compatibility_design.md
T
Felix RiestraandClaude Sonnet 5 cf33a1d019 Add DWG/DXF-native constraint persistence alongside the XRecord format
Implements docs/dwg_constraint_compatibility_design.md's staged plan in
full: materializes the sketch-constraint set as acadrust's real
AssocNetwork/Assoc2dConstraintGroup object graph on save, additive to
the existing OCS_SKETCH_CONSTRAINTS XRecord, so files also carry
constraints AutoCAD/BricsCAD recognize as their own native objects
(per Hakan's request on the upstream project). Covers every
ConstraintKind, including AssocVariable wiring for named-parameter
driving values, and clears+rebuilds on each save.

DWG round-trips the full dependency chain; DXF only keeps the
constraint-group shell since acadrust's own DXF writer deliberately
omits AssocDependency-family objects. Both behaviors are covered by
tests and documented in the design doc's progress section, along with
two other implementation-time findings (skipping the NOD-anchored
top-level network, and the DWG writer's root-node/connections wire
format).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:26:53 +02:00

27 KiB

DWG-Native Constraint Compatibility — Design

1. What this is, and why

Hakan (upstream maintainer) reviewed the sketch-constraint system (docs/parametric_system_design.md) and asked for it to persist using the constraint object model already exposed in acadrust/cadcodec (the pinned CAD kernel), so a file saved by OpenCADStudio carries constraints AutoCAD or BricsCAD would recognize as their own native objects — not just an opaque custom blob only this app understands.

This is purely a persistence-format question, not a solver question. ocs_gcs (the FreeCAD planegcs port: crates/ocs_gcs/src/solvers/{bfgs,dogleg,lm}.rs) is the numerical engine that computes geometry from a constraint system — that stays exactly as it is regardless of what this document decides. What changes is only how SketchConstraintSet gets written to and read from a CadDocument, in src/scene/sketch_persist.rs, currently a custom bincode-in-XRecord blob keyed OCS_SKETCH_CONSTRAINTS.

2. What acadrust already provides (confirmed from source)

All paths below are relative to the pinned checkout (~/.cargo/git/checkouts/cadcodec-*/*/src/, rev pinned in Cargo.toml).

  • objects/associative.rs defines AutoCAD's real 2D geometric-constraint object graph as plain Rust types, reachable generically through ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(Assoc2dConstraintGroup), .. }) — the same document.objects: HashMap<Handle, ObjectType> map sketch_persist.rs already inserts XRecord/Dictionary entries into.
  • Both DWG (io/dwg/dwg_stream_{readers,writers}/object_{reader,writer}/associative.rs, ~1240/~870 lines) and DXF (io/dxf/{reader,writer}/section_{reader,writer}/associative.rs) have complete read and write implementations for every type below — confirmed by reading the actual field-by-field (de)serialization code, not inferred from the type definitions alone.
  • classes/mod.rs registers the DXF class names every one of these needs when written ((DXF_NAME, "AcDbClassName", ...) tuples): AcDbAssocDependency, AcDbAssocGeomDependency, AcDbAssocAction, AcDbAssocNetwork, AcDbAssoc2dConstraintGroup, AcDbAssocVariable, AcDbAssocPersSubentManager.

The graph shape

Not a flat list — a small object graph, matching AutoCAD's real associative framework:

AssocNetwork (per block/space that owns constrained geometry)
  └─ owned_actions: Vec<Handle> ─→ AssocAction
                                     ├─ action_body: Handle ─→ Assoc2dConstraintGroup
                                     │    ├─ work_plane: [Vector3; 3]
                                     │    └─ nodes: Vec<AssocConstraintNode>
                                     │         (both the *constraint* nodes
                                     │          and the *geometry* nodes live
                                     │          in this one Vec — see §3/§4)
                                     └─ dependencies: Vec<AssocActionDependency>
                                          ─→ AssocGeomDependency
                                               ├─ dependency: AssocDependency
                                               │    └─ dependent_on: Handle
                                               │         (the real Line/Circle/... entity)
                                               └─ persistent_subent: AssocPersistentSubentId

AssocConstraintNode { node_id: i32, status: u8, connections: Vec<i32>, class_name: String, registry_flag: bool, data: AssocConstraintNodeData } — node_id/connections link nodes to each other within the group (a constraint node's owner_id fields point at a geometry node's node_id); class_name picks which AssocConstraintNodeData variant applies on read (read_constraint_node_data(reader, class_name), dwg_stream_readers/object_reader/associative.rs:663).

2b. Primary sources beyond cadcodec's own code

cadcodec ships no constraint-bearing test fixture and its own source only shows what bytes get read, not what they mean semantically — §7 (now mostly resolved) needed Autodesk's own interface documentation to answer. Two real, first-party sources turned out to be publicly available with no sample DWG file needed at all:

  • The ObjectARX 2012 SDK's own C++ headers, mirrored on GitHub at Stalso/CadPlugin — Autodesk's own copyright header on each file explicitly grants "permission to use, copy, modify, and distribute this software... for any purpose and without fee", and this is the SDK Autodesk itself publishes specifically so third parties can build interoperable tools — not leaked or decompiled source. AcDbAssoc2dConstraintGroup.h, AcDbAssocNetwork.h, AcDbAssocManager.h, AcDbAssocVariable.h, AcConstrainedGeometry.h were all read directly; their class-level doc comments describe the exact same object graph cadcodec implements, in Autodesk's own words.
  • Autodesk's live public ObjectARX reference (help.autodesk.com/view/OARXMAC/2025/...) — confirmed the AcDb::ImplicitPointType enum's exact values and per-member descriptions (used for §4's point_type mapping) and AcDbAssocNetwork's getInstanceFromObject/getInstanceFromDatabase dictionary-key default (used for §7 question 1). No login or SDK download needed — the reference guide is indexed and publicly browsable.

3. Constraint-kind mapping

Our ConstraintKind (src/scene/sketch_constraints.rs) against AssocConstraintNodeData (objects/associative.rs:885). The "refs shape" column is copied from sketch_solve::build_constraint's actual match (src/scene/sketch_solve.rs:151-208) — the ground truth for what each kind expects, not the doc comment.

ConstraintKind refs shape (from build_constraint) AssocConstraintNodeData Notes
Coincident [point, point] Geometrical + class_name = "ACPOINTCOINCIDENCECONSTRAINT"
Horizontal [whole_line] Geometrical + "ACHORIZONTALCONSTRAINT"
Vertical [whole_line] Geometrical + "ACVERTICALCONSTRAINT"
Parallel [whole_line, whole_line] Parallel { datum_line_index: None, .. } dedicated variant, not Geometrical
Perpendicular [whole_line, whole_line] Geometrical + "ACPERPENDICULARCONSTRAINT"
Equal (line case) [whole_line, whole_line] Geometrical + "ACEQUALLENGTHCONSTRAINT"
Equal (circle/arc case) [whole_circle, whole_circle] Geometrical + "ACEQUALRADIUSCONSTRAINT" our Equal doesn't distinguish these — see §5
Distance [point, point] Distance { direction_type, distance: Option<Vector3>, value_dependency, dimension_dependency, .. } needs an AssocVariable/value dependency for the driving value — see §5
Angle [whole_line, whole_line] Angle { sector_type, value_dependency, dimension_dependency, .. } same value-dependency need
Radius [whole_circle] RadiusDiameter { mode, value_dependency, dimension_dependency, .. } mode presumably distinguishes radius vs. diameter — we're always radius
Tangent [circle, circle] or [circle, line]/[line, circle] — Line-Line and anything involving an Arc are explicitly unsupported today (sketch_solve.rs:239-241, Arc isn't a supported EntityGeom variant yet) Geometrical + "ACTANGENTCONSTRAINT" our own Arc-tangent gap predates this document, unrelated to DWG compatibility

AutoCAD's is_plain_geometrical_constraint list also includes several kinds we have no ConstraintKind for at all: ACCENTERPOINTCONSTRAINT, ACCOLINEARCONSTRAINT, ACCONCENTRICCONSTRAINT, ACEQUALCURVATURECONSTRAINT, ACEQUALDISTANCECONSTRAINT, ACEQUALHELPPARAMETERCONSTRAINT, ACFIXEDCONSTRAINT, ACMIDPOINTCONSTRAINT, ACNORMALCONSTRAINT, ACPOINTCURVECONSTRAINT, ACSYMMETRICCONSTRAINT — genuinely nothing to map to yet, not a mapping gap (§5).

4. Geometry-node mapping

Every entity a constraint references also needs its own node in the same nodes: Vec<AssocConstraintNode>, so constraint nodes have something to point owner_id at, and a paired AssocGeomDependency (in the owning AssocAction's dependencies) so the graph — not just the constraint data — actually references the real Line/Circle/... entity by Handle.

Our reference AssocConstraintNodeData geometry variant
SketchRef::whole(line) BoundedLine { point, direction, is_ray: false, start_point, end_point, .. } — confirmed a bounded line is the right choice: Line/BoundedLine's split exists because AutoCAD's own constrained-geometry set includes true infinite construction lines (ACCONSTRAINEDCONSTRUCTIONLINE/ACCONSTRAINEDDATUMLINE, read_constraint_node_data, associative.rs:711-714) as a separate case from an ordinary bounded segment; nothing we draw is an infinite line
SketchRef::point(line, 0|1) ImplicitPoint { point_type: kStartImplicit(0)|kEndImplicit(1), point_index: -1, curve_id: <the Line/BoundedLine node's node_id>, .. } — confirmed, AcDb::ImplicitPointType enum, kStartImplicit/kEndImplicit/kMidImplicit/kCenterImplicit/kDefineImplicit (0-4), Autodesk's own live ObjectARX reference (§2b). point_index is -1 for every case except kDefineImplicit (a spline control point, which we don't reference); curve_id is presumably the owning geometry node's own node_id (not yet cross-checked against a real file — the values are confirmed, this one wiring detail isn't)
SketchRef::whole(circle) Circle { center, normal, direction, radius, start_parameter: 0.0, end_parameter: TAU, .. }
SketchRef::whole(arc) Arc { .. same fields .. , start_point, end_point }
SketchRef::center(circle|arc) ImplicitPoint { point_type: kCenterImplicit(3), point_index: -1, curve_id: <the Circle/Arc node's node_id>, .. } — confirmed by the same enum (kCenterImplicit: "Center point of a circle, arc, ellipse or bounded ellipse") — not the separate Point/ACCONSTRAINEDPOINT variant, which the ObjectARX docs' own class hierarchy (§2b) show is for a free-standing constrained point, not one implicitly defined by another curve
SketchRef::point(_, -2) (reserved, unused today) would be kMidImplicit if ever used — "Mid point of a bounded line ... or arc" is exactly what marker -2's doc comment already reserved this for

SketchRef and this graph already agree on one thing directly: our own doc comment (src/scene/sketch_constraints.rs:20-22) says the marker convention was deliberately borrowed from AssocDimensionReference::main_gs_marker (src/scene/dimension_assoc.rs, already shipping, already reading/writing real Assoc* objects for associative dimensions). That's a different, lighter mechanism than the constraint graph (an AcDbOsnapPointRef chain attached directly to a Dimension entity, not the AssocNetwork/AssocAction framework — see AssocDimensionAssociation, objects/associative.rs:1200), so it's a precedent for how this codebase already talks to Assoc* types, not a template for the constraint graph's own wiring.

5. Known gaps (not addressed by this document)

  • Equal is one ConstraintKind, AutoCAD has (at least) two: ACEQUALLENGTHCONSTRAINT (lines) vs. ACEQUALRADIUSCONSTRAINT (circles/arcs), plus ACEQUALCURVATURECONSTRAINT/ ACEQUALDISTANCECONSTRAINT we don't model at all. build_constraint already branches on refs' resolved shape at solve time (sketch_solve.rs:181-188), so the same branch can pick the write-side class_name — no data-model change needed for the length/radius split; the other two AutoCAD sub-kinds are a real, separate gap.

  • DrivingValue::Named — confirmed, has a real native equivalent, and the full wiring is now traced end to end. AcDbAssocVariable (objects/associative.rs:1024: name, expression, evaluator, description, cached value) is fully read and written on both DWG and DXF (confirmed in dwg_stream_readers/object_reader/associative.rs:1031-1063 and the DXF reader/writer equivalents) — it's the same associative-action graph a constraint group lives in (AssocVariable wraps an AssocAction, exactly like Assoc2dConstraintGroup does), not a separate table.

    The two handles on Distance/Angle/RadiusDiameter (read_explicit_constraint, associative.rs:652-661 — reads them in this order: value_dependency first, dimension_dependency second) resolve to two different, unrelated things:

    • value_dependency → an AssocValueDependency object ({ dependency: AssocDependency, name, value: AssocEvalVariant }, objects/associative.rs:554). Its own value: AssocEvalVariant ({ code: i16 /* DXF resbuf type */, value: AssocEvalValue::Real(f64) }, confirmed associative.rs:579-596) is the cached, already-resolved number — always present, read fast without evaluating anything. The embedded AssocDependency.dependent_on: Handle (read_dependency, associative.rs:83-109) is what actually links to a named parameter: Handle::NULL when the driving value is a bare literal, or an AssocVariable's handle when it's driven by one. This gives a direct, confirmed mapping:
      • DrivingValue::Literal(v) → AssocValueDependency with value = Real(v), dependency.dependent_on = Handle::NULL.
      • DrivingValue::Named(name) → the same, but dependency.dependent_on points at an AssocVariable{ name, expression: <our formula>, .. } object (create once per distinct name, reused by every constraint referencing it — value still carries the current resolved number, kept in sync with ParameterTable's last evaluation, same duty our own dof/conflicts caches already have).
    • dimension_dependency → a separate dependency chain (AssocAnnotationDependency/AssocDimDependencyBody, associative.rs:744-751 and :1077-1082) linking to the actual Dimension entity AutoCAD draws next to a constraint to show its current value on screen — nothing to do with named parameters at all. Not required for our purposes unless we also want the constraint to carry a visible on-canvas dimension the way AutoCAD's does (open question, not yet decided — §7).

    Remaining gap before mapping directly: AutoCAD's expression is evaluated by a named, extensible evaluator (not fixed) — real-file survey needed to know what evaluator name(s) actually appear in practice (§7 open question 4), ours has one hardcoded evaluator.

  • Missing ConstraintKinds: Concentric, Colinear, Symmetric, Fixed, Midpoint, Normal, CenterPoint, PointOnCurve — real AutoCAD constraint types with no ConstraintKind variant to map from at all. Out of scope until/unless the sketch-constraint system itself grows them.

  • Tangent involving an Arc has no native mapping to design, since build_constraint doesn't support it at all yet (sketch_solve.rs:239-241 — Arc isn't a supported EntityGeom variant in that function). A pre-existing gap in the constraint system itself, unrelated to and not blocking this document.

6. Persistence architecture: additive, not a replacement

Proposal: write both. Keep OCS_SKETCH_CONSTRAINTS as the authoritative round-trip format OpenCADStudio itself reads back (sketch_persist::load_sketch_constraints_from_document), and additionally materialize the AssocNetwork/.../Assoc2dConstraintGroup graph alongside it on save, for other applications to read.

Reasoning:

  • Our own model carries information the native graph can't represent losslessly today — DrivingValue::Named chief among them (§5), also enabled (a suppressed-but-kept constraint) which may or may not have a clean native equivalent (AssocConstraintNodeData's is_active field on several variants looks like the right fit, but unconfirmed, §7). Round-tripping through the native format only and reconstructing our model from it risks silent data loss on every save/reopen cycle.
  • The native graph's own geometry-node fields (center, radius, point, ...) are snapshots of the current solved geometry, not derived at load time — so they need to be kept in sync with whatever ocs_gcs last solved, same as we already do for our own tessellated wire geometry. Two representations of "the same" constraint, kept consistent by one save path, is a smaller invariant to maintain than trying to make the native graph the only source of truth for a system it can't fully express.
  • This is reversible: if the native graph turns out to work well enough (once tested against real AutoCAD/BricsCAD opens, §7) to become the primary format later, dropping the redundant OCS_SKETCH_CONSTRAINTS write is a small follow-up, not a rewrite.

7. Open questions

Three of the original five are now resolved from Autodesk's own primary sources (§2b), not just inferred from cadcodec's struct shapes — kept here with their answers so the reasoning stays visible, not deleted. cadcodec itself still ships no constraint-bearing DWG/DXF fixture and no constraint-specific test (checked: no hits for Assoc2dConstraintGroup/ AssocConstraintNode under any tests/ directory or fixture path in the dependency), so even the resolved ones haven't been checked against a real written-and-reopened file yet — that step still matters (§8 stage 1) for confirming implementation correctness, just not for the design questions below anymore.

  1. Resolved. Where the top-level AssocNetwork attaches: AcDbAssocNetwork::getInstanceFromDatabase/getInstanceFromObject's own doc comments (§2b) state the default sub-dictionary key directly — "ACAD_ASSOCNETWORK" — used both for the one top-level network (owned by the database's Named Object Dictionary) and for each per-block-record network (owned by that AcDbBlockTableRecord's own extension dictionary — confirming the parallel to OCS_SKETCH_CONSTRAINTS's own attachment was right). The two are linked: creating a per-block network with addToTopLevelNetwork = true (the default) also registers it as an action inside the top-level network, so evaluating the top-level network cascades through every block's sub-network.
  2. Resolved. Sub-entity addressing for ImplicitPoint: confirmed the real addressing lives entirely in the constraint-node's own point_type/point_index/curve_id fields, not in AssocPersistentSubentId (that type turned out to be an abstract C++ base class for a different, more general subentity-tracking need elsewhere in the associative framework — irrelevant to 2D constraint geometry). AcDb::ImplicitPointType's exact enum (§4) settles the mapping.
  3. Still open. Whether a partially-populated graph (e.g. omitting AssocPersSubentManager, or leaving some AssocAction bookkeeping fields at their Default) still opens cleanly in real AutoCAD, or whether it requires every field correctly populated to avoid a repair prompt or silent constraint loss. Genuinely needs a real write-then-open test, not documentation — this is a robustness/tolerance question, not a structural one.
  4. Narrowed to almost nothing. AssocVariable's evaluator field: AcDbAssocVariable.h's own doc comment (§2b) says plainly — "If empty, the default evaluator for current acad version will be used." Leaving it as an empty string is officially-supported behavior per the SDK itself, not a guess. No further investigation needed unless a real file later shows this doesn't round-trip as expected.
  5. Still open. DWG version gating — AssocConstraintNode's own reader already branches on version.r2013_plus(dxf_version) for one field (dwg_stream_readers/object_reader/associative.rs:604); unclear whether other fields need per-version handling this document hasn't looked for. Likely answerable the same way as questions 1/2/4 were (Autodesk's own docs usually note version-specific members) — just not done yet.
  6. Still open, low priority. Whether to bother wiring dimension_dependency at all (§5) — it drives the small on-canvas dimension label AutoCAD shows next to a constrained value, cosmetic rather than structural. Leaving it Handle::NULL is presumably safe (the constraint itself is still fully defined without it) but unconfirmed; deferring this to a later stage either way (§8).

8. Suggested staged approach

  1. Proof of concept, narrowest possible slice: Horizontal/Vertical on a single Line, no dimensional constraints (sidesteps the AssocVariable/value-dependency wiring entirely, though that's now fully designed too — §5). Write the graph — AssocNetwork at "ACAD_ASSOCNETWORK" on the scope owner's extension dictionary (§7.1), one Assoc2dConstraintGroup action, one BoundedLine geometry node, one Geometrical/"ACHORIZONTALCONSTRAINT" (or vertical) constraint node — and open the file in whatever real-DWG-reading tool is available, to confirm AutoCAD/BricsCAD actually shows a constraint icon on the line and doesn't flag the file as corrupt. This is what answers open question 3 (partial-graph tolerance) — the one remaining question documentation alone can't settle.
  2. Geometric constraints, still no driving value: add Coincident (needs ImplicitPoint/kStartImplicit/kEndImplicit, §4, now confirmed), Parallel, Perpendicular, Equal (both sub-kinds) — verify against the same real-file test from stage 1.
  3. Dimensional constraints: Distance, Angle, Radius, using the now fully-traced value_dependency → AssocValueDependency → AssocVariable chain (§5) — including deciding whether DrivingValue::Named gets the real native mapping designed there, or stays a resolved-literal degrade for a first pass.
  4. Everything else: Tangent's non-circle-circle cases, the missing ConstraintKinds (§5) if/when the sketch-constraint system grows them, dimension_dependency (§7.6) if a visible on-canvas dimension label turns out to matter.

Each stage's file should be hand-inspected (or opened in real software) before moving to the next — the graph's correctness genuinely can't be verified by this codebase's own round-trip tests alone, since a bug that's symmetric (write it wrong, read the same wrongness back) would pass here and still fail to open correctly elsewhere.

9. Progress

Implemented (src/scene/dwg_native_constraints.rs, wired into both save paths in src/app/update/file.rs alongside the existing materialize_sketch_constraints_for_save/materialize_named_parameters_for_save calls). All 4 stages from §8 landed together rather than incrementally, since ConstraintKind only has the 10 variants already covered — there was no narrower slice left once Horizontal/Vertical's plumbing (network/dictionary creation, the two-pass builder, class-table registration) was in place. Covers: every ConstraintKind (Coincident, Horizontal, Vertical, Parallel, Perpendicular, Equal's line/circle split, Distance, Angle, Radius, Tangent), AssocGeomDependency for whole-geometry nodes, AssocValueDependency/AssocVariable for dimensional constraints including DrivingValue::Named, and a resave-clears-and-rebuilds cleanup pass (remove_owned_recursive) so materializing twice doesn't accumulate orphans. Verified via real save_to_bytes/load_bytes round trips through acadrust's own reader/writer (5 tests in that module) — this confirms self-consistency with acadrust's implementation of the documented format, not real-world AutoCAD/BricsCAD compatibility (§7.3/§7.5 below remain unverified; no such software is available in this environment).

Two implementation-time findings not anticipated by the design above:

  • The top-level NOD-anchored network was dropped from scope. named_parameters_persist.rs's own doc comment already documents a real acadrust bug: a Dictionary-type object (like the database's Named Object Dictionary) used as an XRecord/dictionary owner loses its extension data through a DXF save specifically (CadDocument::ensure_xrecord only patches the owner's xdictionary_handle when get_entity_mut(owner) succeeds — a Dictionary isn't an entity, so it silently falls back to a DWG-only side map). Rather than hit that bug, this module only creates a network per SketchScope::owner_handle() (a real BlockRecord entity) — the same already-proven-safe attachment point sketch_persist.rs uses. No top-level network is created at all.
  • DXF cannot carry the dependency chain — DWG is the only fully-faithful format. Confirmed by reading acadrust's DXF writer (io/dxf/writer/section_writer.rs's is_unrestorable_assoc_object): it deliberately skips writing AssocDependency/AssocValueDependency/ AssocGeomDependency/AssocVariable objects to DXF at all ("Unrestorable associative-framework objects are not written") — DWG has no equivalent filter. A DXF save keeps the Assoc2dConstraintGroup/ AssocNetwork shell and every node's own embedded geometry, but loses live geometry/value linkage and every AssocVariable. This module still materializes the same graph for both formats (the shell that survives DXF is strictly more than nothing, and dangling handles are an ordinary, tolerated pattern in both formats) rather than special-casing DXF to write less. Demonstrated directly by a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable vs. a_dxf_save_keeps_only_the_constraint_group_shell in that module's test suite.

Also confirmed while implementing (not previously documented): the DWG writer's Assoc2dConstraintGroup arm only emits node data at all when nodes.first() is Some — an empty nodes Vec skips node serialization entirely — so nodes[0] must always be a synthetic root pseudo-node (empty class_name, connected to every real node) even for a single-constraint graph; and a multi-geometry constraint (e.g. Parallel) references its second geometry via the generic connections: Vec<i32> field on AssocConstraintNode, not a second dedicated field on AssocConstraintNodeData::Parallel — confirmed from AcConstraintGroupNode's own doc comment ("the connection between two nodes is not directed").

Of the original five open questions, three were resolved from Autodesk's own primary documentation (§7.1, 7.2, 7.4) before implementation started — the network's dictionary key, ImplicitPoint's addressing enum, and the AssocVariable evaluator default were confirmed, not guessed. Two remain open (§7.3 partial-graph tolerance, §7.5 version gating) and still genuinely need a real AutoCAD/BricsCAD open, not just more reading — self-consistency through acadrust's own round trip can't settle either one. Next action is opening a saved DWG in real software (or at minimum a third-party DWG inspector) to make progress on those two, and posting the GitHub Discussion Hakan asked for once that's done.