Files
OneCAD-Draft/docs/parametric_system_design.md
T
Felix RiestraandClaude Sonnet 5 63a74b19a9 Add persistent sketch-constraint system (ocs_gcs port) and named-parameters handoff docs
Ports FreeCAD's planegcs 2D geometric constraint solver to Rust as a new
crates/ocs_gcs workspace member, and wires it into the document/scene model
as a persistent SketchConstraintSet: Coincident, Horizontal/Vertical,
Parallel/Perpendicular, Equal, Tangent, Distance, Angle and Radius
constraints that re-solve live (including mid-drag, not just on release),
render glyphs and a DOF badge, flag conflicts with a one-click resolver
pill, and round-trip through save/load via an XRecord blob.

Also includes docs/command_reference.md (a statically generated reference
of every registered command and its prompt sequence) and
docs/named_parameters_design.md + docs/backlog.md, which hand off the next
queued feature (a named-parameter/expression table) and consolidate every
deliberate scope-down and open gap found along the way, so a fresh session
has a complete starting point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:19:23 +02:00

70 KiB
Raw Blame History

Persistent Parametric Constraint System — Design

Status: design/research only, no code written against this doc yet. Scope: Section 5 (document-model integration) and Phase B/C of Section 4 (UI) of /Users/felix/.claude/plans/snoopy-finding-matsumoto.md, made concrete against the actual OpenCADStudio codebase as of this session.

The one-shot "Constraints" ribbon group already live in the app (src/modules/draw/constrain/{mod.rs,tools.rs,value.rs}, dispatched from src/app/commands/draw.rs lines 960–1029) is explicitly a Fillet/Trim-style single operation — it snapshots coordinates into a fresh ocs_gcs::ParamStore, solves once, writes back, and remembers nothing. This document designs the real thing: constraints that attach to entities, persist through save/load, and re-solve automatically when constrained geometry changes.

1. What the investigation found

1.1 Document/entity model and undo/redo

Scene (src/scene/mod.rs:1501) wraps pub document: CadDocument (acadrust::CadDocument, an external crate — see §1.4). Entities live in a flat store addressed by acadrust::Handle, which is a newtype over u64 (.../cadcodec-.../src/types/handle.rs:13) and is the actual DWG/DXF persistent handle, not a session-local index. CadDocument::allocate_handle (.../document.rs:1965) mints new ones; nothing in the codec reuses a handle after an entity is erased (erase leaves a hole, undo/redo of an erase restores the original handle via restore_entity_arc, src/scene/mod.rs:2567). Handles are stable across undo/redo and across save/load — this is the foundation the whole addressing scheme below relies on.

Undo/redo is a delta model, not full-document snapshots for ordinary edits (src/app/document.rs:642-812, src/app/history.rs):

  • Scene::begin_undo_recording (src/scene/mod.rs:2480) opens an UndoRecording that captures, per handle, the first-touch before-image (record_undo_before, :2499, no-op if not recording — callers guard with is_recording_undo()).

  • The app-level wrapper is OpenCADStudio::begin_undo (src/app/history.rs:372) / commit_undo_delta (:701), which pairs each recorded before-image with the entity's current after-image and pushes one symmetric DeltaSnapshot (src/app/document.rs:723) onto the undo stack. Undo applies the before side, redo the after side — same allocation moves between stacks (src/app/document.rs:717-720 doc comment).

  • Scene::bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) (:2612) is the single choke point every mutation goes through: it bumps geometry_epoch, invalidates render/pick caches, and — critically for this design — already runs a chain of derived-geometry recompute passes in-line, before the epoch bump:

    • refresh_associative_centerlines / refresh_associative_center_marks (src/scene/centerline.rs:327, src/scene/centermark.rs:353)
    • refresh_associative_dimensions (src/scene/dimension_assoc.rs:880)
    • refresh_associative_hatches (src/scene/boundary.rs:680)

    Each of these takes the incoming changes slice, finds document objects that depend on the changed handles, recomputes their derived geometry, and appends its own (Handle, ChangeKind::Modified) entries to the same changes vec (src/scene/mod.rs:2624-2643) rather than calling bump_entities again — so there is no re-entrant bump and no infinite loop. The centerline/centermark passes additionally call is_recording_undo() + record_undo_before() before they mutate (src/scene/centerline.rs:338-340, src/scene/centermark.rs:343-345, etc.) so their writes fold into whatever undo delta the triggering edit is already recording, producing one undo step for the whole cascade. The dimension/hatch passes do not bother (their output is fully re-derivable from source geometry, so it doesn't need its own undo record).

    This is precisely the mechanism a SketchConstraintSet re-solve needs, and the precedent already establishes the exact call contract to match.

  • changes_touch_block_definition (:2698) and the layout_blocks / BlockRecord scan there confirms block records are the app's only existing sub-document/grouping concept. BlockEditSession (src/modules/draw/modify/block_edit.rs:47) scopes an interactive edit session to one br_handle: Handle (a block definition), with its own entity snapshot and camera. There is no "Sketch" entity or block-scoped named group beyond ordinary block records — see §3.1.

1.2 Sub-element (point-level) identity — already exists, twice

The task's open question #2 ("can you address 'the start point of line handle 7' independently of the whole line?") has a concrete answer: yes, two independent schemes already do this, both usable as a template:

  1. Grip identity. GripDef (src/scene/model/object.rs:105) has pub id: usize, documented as "Object-local identifier (stable index, unique per object instance)". A GripEdit/GripTarget (src/scene/pick/grip.rs:29-56) addresses a drag target as (handle: Handle, grip_id: usize). For a Line, grip 0/1 are (by established convention in every entity's grip producer) the endpoints.
  2. Associative-dimension reference identity, which is the closer match because it is a persisted, serialized sub-element reference, not a transient UI index. AssocDimensionReference (.../cadcodec-.../src/objects/associative.rs:1253) has xrefs: Vec<Handle> (the source entity) + main_gs_marker: i32 (an AutoCAD "graphical subentity marker" — the same GsMarker concept OSNAP uses). resolve_reference (src/scene/dimension_assoc.rs:342-409) shows the resolution rule: negative markers are special cases (circle/arc center = -3, point-at-parameter = -2), and a non-negative marker indexes into source_points(entity) — an ordered Vec<Vector3> of named points per entity type (line start/end, etc.), i.e. exactly the GsMarker-indexed point-list scheme ocs_gcs constraint endpoints need, and it is literally what real DWG/DXF files already carry for OSNAP-based dimension associativity — this is not an invented scheme, it's the format's own.

Neither scheme currently exists as a reusable public API outside dimension_assoc.rs/grip.rs — source_points() is private to that module (confirm before depending on it; it may need to move to a shared location, or the constraint layer defines its own equivalent enumeration function per entity type — see open questions).

1.3 Re-solve trigger mechanics — no push-based observer exists

There is no general "on change" callback/subscriber system in Scene. Change propagation is pull/epoch-based: geometry_epoch + GeometryDelta journal (src/scene/mod.rs:372-386) that consumers replay via replay_since (:2307), used for GPU buffer diffing and picking-index maintenance — not a generic reactive hook a new module could subscribe to independently. The only place that currently reacts synchronously to a mutation before it's finalized is the in-line derived-geometry chain inside bump_entities itself (§1.1). This settles the trigger design: a constraint re-solve must be another link in that same chain, not a separately-registered listener — see §4.

ocs_gcs::system::System (crates/ocs_gcs/src/system.rs:22-58) has no incremental/dirty-tracking API: add_param/add_constraint, then partition() rebuilds SubSystems from scratch. There is no "touch this constraint and re-partition only its component" operation. This matches FreeCAD's own planegcs usage (a full rebuild-and-resolve per sketch recompute) and settles the naive-vs-incremental question: always rebuild a fresh System/ParamStore from current document coordinates + the persisted constraint records, every re-solve (§4.2) — building an incremental solver session is out of scope and not something the existing crate API supports without new work.

Grip-drag preview state (GripEdit/preview_wires, src/scene/mod.rs:1601-1609) is kept separate from the committed document — the document (and therefore bump_entities) is only touched at drag commit, not per mouse-move frame. This is the natural debounce point: a live drag already doesn't call bump_entities per frame, so a constraint re-solve hooked into bump_entities automatically only fires at commit, with no extra debounce logic needed for the drag case. (It will fire once per discrete edit — one click of a Move/Stretch/grip commit, one property edit, one paste — which is the right granularity, matching how refresh_associative_dimensions etc. already behave.)

1.4 File format and persistence

Default save format is DWG (AC1032/R2018+) via acadrust (src/io/mod.rs:4, save_as_version/save_to_bytes at :1546/:1717); DXF is also supported through the same codec. There is no separate "OpenCADStudio native" serde dump of the whole document — serde/ serde_json/bincode in Cargo.toml are used elsewhere (plugin IPC, ocs_doc_api, see src/app/doc_api.rs), not for saving drawings. This means any new Rust struct added to Scene/DocumentTab that isn't threaded through acadrust's object model will silently NOT survive a save/load round-trip — it would only live for the session. A SketchConstraintSet must be represented as data the DWG/DXF codec actually writes and reads.

Two ways to make that true, found in acadrust:

  • Native option: ObjectType::Associative with AssociativeData::ConstraintGroup(Assoc2dConstraintGroup) (.../objects/associative.rs:74, :1069-1091) is AutoCAD's own AcDbAssoc2dConstraintGroup — the real object AutoCAD's "Geometric Constraints" feature uses. It has a full, real writer (not a passthrough stub) in both DWG (.../io/dwg/dwg_stream_writers/object_writer/associative.rs:791-852) and DXF (.../io/dxf/writer/section_writer/associative.rs:787). Its node vocabulary (AssocConstraintNodeData, :956-1065) covers Point/Line/Circle/Arc/Ellipse geometry nodes and Angle/Distance/ RadiusDiameter/Parallel dimensional/geometric nodes with geometry_dependency: Handle + geometry_node_id: i32 — but it does not have explicit node kinds for Coincident, Horizontal, Vertical, Equal, Perpendicular, or Tangent, and its connections: Vec<i32> graph encodes AutoCAD's own internal action-dependency network, which is not publicly documented and was itself reverse-engineered into this crate. Faithfully mapping all ~30 ocs_gcs constraint types onto this vocabulary 1:1, in a way a real AutoCAD build would also interpret correctly, is a substantial and separately-risky undertaking — not recommended for v1 (see recommendation below).
  • Extension option: ObjectType::XRecord(XRecord) (.../objects/xrecord.rs:383-403) is the standard AutoCAD ARX extensibility object third-party applications use to carry private data through a DWG/DXF file without needing format-level support for their specific feature. XRecordEntry/XRecordValueType includes a Chunk (binary blob) variant (:57-82). XRecords hang off a Dictionary (.../objects/mod.rs:136) via xdictionary_handle, and the document's root is CadDocument.header.named_objects_dict_handle (.../document.rs:580, initialized at :1529) — the exact place well-behaved third-party AutoCAD data already lives in real DWG files.

Recommendation: use the XRecord/Dictionary extension mechanism, not Assoc2dConstraintGroup. Concretely: serialize the whole SketchConstraintSet for one scope (§3.1) with a stable, versioned binary format (the app already depends on bincode) into one XRecord's Chunk entry, owned by a per-scope Dictionary entry hung off named_objects_dict_handle under an app-specific key (e.g. "OCS_SKETCH_CONSTRAINTS"). This requires zero changes to the external acadrust/cadkernel crates, rides an already-fully-implemented read/write path, and only needs OpenCADStudio's own reader/writer for the blob's contents to agree with itself — no AutoCAD-compatibility burden. The tradeoff, called out explicitly as an open risk in §7, is that a DWG saved this way will show no constraint data if opened in real AutoCAD or another vendor's app (it'll just look like an inert XRecord); pursuing true AcDbAssoc2dConstraintGroup interop is a valid future enhancement once v1 ships, not a blocker.

2. Core data structures

All in a new module crates/ocs_gcs-adjacent but living in the app crate (not ocs_gcs itself, which must stay UI/document-free per the existing port plan) — proposed at src/modules/sketch/constraint_set.rs, alongside a constraint_map.rs mirroring the plan's Section 4 note.

/// One endpoint a constraint attaches to: an entity handle plus which
/// sub-element of it. Mirrors the existing AssocDimensionReference /
/// GsMarker scheme (§1.2) rather than inventing a new one.
pub struct SketchRef {
    pub entity: acadrust::Handle,
    /// None = the entity as a whole (radius/length/whole-curve constraints).
    /// Some(marker) = an indexed sub-point, same convention as
    /// `main_gs_marker` / `source_points()`: non-negative indexes a
    /// per-entity-type ordered point list (0/1 = line start/end, etc.);
    /// -3 = circle/arc center; -2 = point-at-parameter (osnap_distance
    /// carries the parameter). Reuses rather than reinvents.
    pub marker: Option<i32>,
}

/// One user-facing constraint record: the friendly type plus its
/// endpoints and (for dimensional constraints) a driving value.
pub struct SketchConstraint {
    pub id: ConstraintId,             // stable within one SketchConstraintSet
    pub kind: ConstraintKind,         // Coincident, Horizontal, Parallel, Distance{value}, ...
    pub refs: SmallVec<[SketchRef; 2]>,
    pub driving_param: Option<f64>,   // e.g. typed distance/angle/radius value
    pub enabled: bool,                // user can suppress without deleting
}

pub type ConstraintId = u32;

/// Everything ocs_gcs needs to solve one scope's sketch, plus enough to
/// re-derive it. Rebuilt-and-solved wholesale on every trigger (§4.2) — no
/// incremental state is retained here beyond the constraint records
/// themselves.
pub struct SketchConstraintSet {
    pub scope: SketchScope,                      // §3.1
    pub constraints: Vec<SketchConstraint>,
    next_id: ConstraintId,
}

ConstraintKind is the friendly-name enum from the plan's Section 4 (Coincident, Horizontal, Vertical, Parallel, Perpendicular, Equal, Distance, Angle, Radius, Tangent, …) — the mapping layer already sketched as constraint_map.rs in the plan's Phase A. Each variant maps to one or more ocs_gcs::constraints::* primitive constructions, exactly as apply_horizontal/apply_parallel/etc. in src/modules/draw/constrain/tools.rs:83-200 already do per-call — the new code differs only in persisting the record and re-running that same mapping on every solve instead of once.

2.1 Where it's stored at runtime

Not in CadDocument (external crate, don't touch) and not bolted onto Scene as a bare field either — add it to DocumentTab (src/app/document.rs:106-218), the per-tab session state, as:

pub(super) sketch_constraints: Vec<SketchConstraintSet>, // one per active scope

keyed by SketchScope (below), populated from the document's XRecord blob on open (§5) and reserialized into it on save. This keeps ocs_gcs dependency-free and keeps the design consistent with how plugin_state (:216) already attaches session-scoped, non-CadDocument state to a tab.

3. Scoping and addressing

3.1 SketchScope — what "one sketch" means here

The app has no dedicated "Sketch" grouping (§1.1). Given block records are the only existing sub-document concept, and given the task explicitly allows "the whole flat document" as a fallback:

pub enum SketchScope {
    ModelSpace,
    Block(acadrust::Handle), // a BlockRecord handle, matching BlockEditSession::br_handle
}

v1 recommendation: one SketchConstraintSet per space — model space gets one, and each block definition gets its own if/when it's edited in BEDIT (BlockEditSession::br_handle, src/modules/draw/modify/block_edit.rs:51). This is not "a real Sketch object" the way FreeCAD has one, but it matches the only grouping boundary that already exists, needs no new document concept, and is forward-compatible: if a real "Sketch" sub-entity concept is added later, SketchScope gains a variant and existing ModelSpace/Block sets are unaffected. Paper space layouts are out of scope (constraints on annotation/layout geometry make little sense) — an edit there simply finds no matching SketchConstraintSet and does nothing.

3.2 Endpoint addressing

Covered in §1.2/§2: SketchRef { entity: Handle, marker: Option<i32> }, directly reusing the GsMarker/source_points() convention already present for associative dimensions. Action item this design depends on: source_points() (or an equivalent) needs to become a small pub(crate) or pub function usable from both dimension_assoc.rs and the new constraint module — likely promoted to a shared location (src/scene/subelement.rs or similar) rather than duplicated. This is a small, mechanical refactor, not a new design.

4. Re-solve trigger — concrete mechanism

4.1 Hook point

Add one more link to the existing in-line chain in Scene::bump_entities (src/scene/mod.rs:2612), after the existing associative-geometry refreshes and before the epoch bump:

// after refresh_associative_hatches, before `if !changes.is_empty() { refresh_dependency_index_for_changes }`
for change in self.refresh_sketch_constraints(&changes) {
    if !changes.iter().any(|(handle, _)| *handle == change.0) {
        changes.push(change);
    }
}

refresh_sketch_constraints lives in the new module (not ocs_gcs) and:

  1. Filters changes to handles that are referenced by some SketchConstraint::refs in some SketchConstraintSet on this tab (a Handle -> Vec<(scope, ConstraintId)> reverse index, rebuilt lazily and invalidated the same way SceneDependencyIndex already is — invalidate_dependency_index, src/scene/mod.rs:2582/:9882 — same pattern, separate cache).
  2. For each affected scope, rebuilds a fresh ocs_gcs::System from that scope's current entity coordinates + SketchConstraint records (§4.2), solves, and — for every parameter that moved — calls is_recording_undo() + record_undo_before() (matching centerline.rs:338-340) then document.get_entity_mut(...) to write the solved coordinates back, and collects (Handle, ChangeKind::Modified) for the return vec.
  3. Returns that vec; bump_entities folds it into changes exactly like the existing associative passes, so one user edit that ripples through several constrained entities still produces one undo step and one epoch bump — no special-casing needed, this falls out of the existing contract for free.

This directly answers "circular triggering": because the solve's writes are folded into the same changes vec inside the same bump_entities call rather than by calling bump_entities again, there is no re-entrancy to guard against — same reason the three existing associative-refresh passes don't loop either. A genuine constraint-graph cycle (e.g. A depends on B depends on A, which can't happen in a well-formed ocs_gcs::System since its partitioning is over undirected constraint-sharing, not a directed dependency) is a different, solver-level concern handled by ocs_gcs already converging or reporting SolveStatus failure — not an infinite recompute loop.

4.2 Full rebuild per trigger, not incremental

Per §1.3, ocs_gcs::System has no incremental API, so refresh_sketch_constraints rebuilds from scratch every time it fires: fresh ParamStore, one store.add() per unique referenced point (deduplicated by SketchRef, so a shared Coincident point becomes one parameter, not two independently-solved ones), one ocs_gcs::constraints::* construction per SketchConstraint, system.partition(), solve each SubSystem. This mirrors both FreeCAD's own architecture and the existing one-shot commands' pattern (ParamStore::new() fresh every call, tools.rs:86/104/122/145/167/186) — consistent with how this port has approached fidelity to planegcs throughout.

Cost model: bounded by one scope's constraint count, not document size, because the reverse index in step 1 above only rebuilds scopes actually touched. A drawing with thousands of unconstrained entities and one 40-line constrained sketch pays for the 40-line resolve, not the whole document — same locality property refresh_associative_dimensions already has by filtering on changed.

4.3 What does NOT trigger a re-solve

  • Selection changes, hover, camera moves, layer visibility — none of these call bump_entities, so none reach this hook. Free.
  • Live grip-drag preview (§1.3) — the document isn't touched until commit, so mid-drag frames never fire a resolve. The dragged-point's constrained neighbors will visibly "lag" behind during the drag (frozen at their pre-drag position) and snap to solved positions on release. This is an intentional, low-risk simplification for v1 — see §7 for the UX tradeoff this implies versus FreeCAD/Fusion 360's live-solve-during-drag feel, and a possible follow-on (§6 staged plan, stage 4).

5. Undo/redo and save/load — concrete integration

5.1 Undo/redo

No new undo mechanism needed. §4.1's record_undo_before calls inside refresh_sketch_constraints make solved-geometry writes participate in whatever DeltaSnapshot the triggering command already opened via begin_undo/commit_undo_delta. A Move command that nudges one endpoint of a Coincident-constrained line, causing three other entities to re-solve, produces one DeltaSnapshot with four entity images and undoes/redoes as one step — the same guarantee refresh_associative_centerlines already gives centerline geometry today.

Gap: adding/deleting a SketchConstraint itself (not a geometry edit, a constraint-set edit) needs its own undo path, since SketchConstraintSet lives on DocumentTab, not in CadDocument's entity/object store, so it's outside the DeltaSnapshot::entities delta model entirely. Two options: (a) give HistorySnapshot a new variant, SketchConstraints(before, after) holding a full SketchConstraintSet clone (cheap — sketches are small, unlike the whole-document Full structure fallback this codebase deliberately avoids for entities), or (b) once §5.2's XRecord persistence exists, route constraint-set edits through the same ObjectEntryDelta / StructureSnapshot::Objects path already used for "Group erase and RasterImage add" (src/app/document.rs:790-794, referenced at :749-751) by writing the serialized blob into the XRecord's entries on every add/remove and letting the existing object-delta undo cover it — this is the recommended option, since it reuses an already-implemted path instead of adding a new HistorySnapshot variant, at the cost of needing the XRecord "live" (i.e. resident in document.objects, not just serialized at save time) — see §5.2.

5.2 Save/load

Two implementation choices interact:

  • "Live" model: SketchConstraintSet also exists as a resident ObjectType::XRecord in document.objects at all times (not just written out at save time), kept in sync on every constraint add/remove/re-solve. This makes §5.1(b) work directly (constraint-set edits ride the ordinary object-delta undo path) and makes save trivial (the XRecord is already correct, save_as_version just writes what's there). Cost: every constraint add/remove/re-solve re-serializes the whole scope's blob into the resident XRecord.entries, which is a Vec — for a small sketch this is cheap (bytes, not megabytes), consistent with the scope-locality argument in §4.2.
  • "Lazy" model: SketchConstraintSet lives only in DocumentTab (§2.1) and gets serialized into a fresh XRecord/Dictionary entry only at save time (save_as_version/save_to_bytes), and deserialized back on open. Simpler to build first, but forces §5.1(a) (a dedicated HistorySnapshot variant) since there's no resident object to hang the existing delta-undo path off of, and requires the save path to reach into DocumentTab-level state (currently save_as_version takes &CadDocument only, per src/io/mod.rs:1546 — would need a new call site that also receives &[SketchConstraintSet], or a pre-save step that materializes them into document.objects transiently before handing off to save_as_version, which converges back to the "live" model anyway at save time).

Recommendation: build the "lazy" model first (stage 4 of §6) as the simplest thing that actually round-trips through save/load, then migrate to "live" once the constraint-set-edit undo gap (§5.1) is worth closing properly — the lazy model's pre-save materialization step is forward compatible with later making it resident permanently.

Concretely, on open: after the document loads, scan document.objects values under the dictionary named "OCS_SKETCH_CONSTRAINTS" (if present) for XRecords, deserialize each Chunk entry into a SketchConstraintSet, populate DocumentTab::sketch_constraints. On save: before calling save_as_version, serialize each SketchConstraintSet and upsert the corresponding Dictionary/XRecord pair into document.objects (creating them via CadDocument::allocate_handle on first save). Versioning: prefix the blob with a format version byte/u32 so a future schema change can be detected and either migrated or rejected gracefully rather than corrupting silently — there is no existing precedent in this codebase for a constraint-schema migration path, so this needs to be designed carefully when the schema first changes (flagged in §7, not solved here).

5.3 Copy/paste and entity deletion

Neither is addressed by the existing associative-geometry passes in a way that generalizes cleanly, and this design does not have a settled answer — flagged explicitly in §7 rather than papered over:

  • Deletion: if an entity referenced by a SketchConstraint is erased, the constraint becomes dangling. refresh_sketch_constraints (§4.1) must detect ChangeKind::Removed for a referenced handle and either drop the constraint (silently, or with a command-line notice) or refuse to complete the delete (FreeCAD does the former; SolidWorks warns). Simple to implement (delete-on-dangling) but not designed against real usage data here.
  • Copy/paste: acadrust::Handles are not preserved across a copy/paste of a subset of entities (a pasted line gets a fresh handle from allocate_handle), so constraints between two copied entities need their SketchRef.entity handles remapped through whatever handle-mapping table the paste command already builds for other cross-entity references (e.g. block INSERT membership, dimension associativity — AssociativeData is presumably remapped somewhere already since dimensions survive copy/paste). This needs to reuse that existing remap table rather than invent a new one, but which command owns that table was not located in this investigation (out of budget — see §7).

6. UI implications

6.1 Phase A (already partially done) — extend to persistent commands

The existing HCONSTRAINT/VCONSTRAINT/PCONSTRAINT/QCONSTRAINT/ ECONSTRAINT dispatch block (src/app/commands/draw.rs:960-1029) becomes the template for persistent variants: same selection UX, same begin_undo/commit_undo_delta bracket, but instead of calling constrain::apply_horizontal (which solves-and-forgets), call a sketch::add_constraint(scope, kind, refs) that (a) appends a SketchConstraint to the tab's SketchConstraintSet for the entities' scope and (b) calls scene.bump_entities(&touched) once to trigger the first solve through the normal §4 path — no separate "solve now" code path needed, add-then-bump reuses everything. Coincident/Distance/Angle/Radius/ Tangent (not yet wired per the plan's progress notes) need sub-entity point picking, which requires the promoted source_points()/marker-picking UI (§3.2) — this is new interaction work, not just new dispatch.

6.2 Phase B — live inference

Hooks into CadCommand::on_point per the plan's Section 4, concretely at the point a draw command returns CmdResult::CommitEntity (e.g. LineCommand::on_point, src/modules/draw/draw/line.rs:74-83, returns CmdResult::CommitEntity(line) on every click after the first). The inference pass belongs at the layer that turns CmdResult::CommitEntity into an actual document write (not inside each draw command — line.rs itself must stay unaware of constraints, matching how it's unaware of undo today), most likely alongside wherever CmdResult::CommitEntity is matched in the app's command-result handler (not located precisely in this pass — grep for CmdResult::CommitEntity at that dispatch site before implementing). The inference itself: for the new entity's endpoints, query nearby existing entities' source_points() within snap tolerance (reusing the existing OSNAP infrastructure's tolerance/query rather than a new spatial index), and if a match is found, auto-add a Coincident SketchConstraint the same way §6.1's add_constraint does — suppressible by a modifier key per the plan's UX research, which needs a new CadCommand::set_modifier-style push analogous to the existing set_ctrl/set_shift (src/command.rs:1857-1862).

6.3 Constraint glyphs, color state, DOF badge

  • Glyphs: an overlay layer alongside src/ui/overlay.rs (confirmed to exist, currently handles crosshair/grip/snap-marker overlays via iced:: widget::canvas, src/ui/overlay.rs:1-40) is the right place — glyphs are screen-space UI chrome anchored to world points, exactly what this module already draws for grips. A new canvas layer reading DocumentTab::sketch_constraints and projecting each constraint's anchor point(s) through the existing camera/UCS transform is additive, no changes to the existing overlay content.
  • Constraint-state color (under/fully-constrained, conflict): the plan's original note pointed at src/scene/render_graph.rs's ByLayer/ ByBlock color resolution (confirmed present, render_graph.rs:87-99,194-198) and an adapt_to_bg in src/scene/project.rs — this investigation could not locate adapt_to_bg (it may have been renamed or moved since the plan was written); treat that specific hook as unconfirmed. Recoloring committed wire geometry based on constraint status also means threading per-entity DOF state into a color-resolution path built for layer/block color inheritance, which is a bigger, riskier change than it sounds. Recommended alternative: render constraint state as glyph color/badges in the overlay layer (§ above) rather than recoloring the entities themselves — same information, no render_graph.rs changes, and it's the more common convention among the referenced incumbents (Onshape/Fusion badge geometry, they don't usually recolor the model itself except for a subtle under-constrained tint SolidWorks does — treat wire recoloring as a stretch goal, not v1).
  • DOF badge: src/ui/statusbar/mod.rs has StatusBar::view (:66) taking a data struct (StatusMenuData, :41) — a small addition there, summing ocs_gcs::diagnosis::diagnose(...).dof across all of the active tab's SketchConstraintSets (only the ones touching the current space) is a cheap, self-contained addition. diagnose is documented as "cheap enough to call after every edit for a live DOF readout" (crates/ocs_gcs/src/diagnosis.rs:58-61) — call it right after refresh_sketch_constraints' solve in §4.1, cache the Diagnosis on the SketchConstraintSet alongside the solve rather than recomputing it again in the status bar's view code.

6.4 Phase C — conflict resolver

ocs_gcs::diagnosis::Diagnosis (diagnosis.rs:41-56) gives dof: usize and redundant: Vec<usize> (row indices into one SubSystem), with redundant_constraints() resolving those back to &Rc<dyn Constraint>. The module doc (:20-31) is explicit that this cannot yet distinguish redundant-but-harmless from genuinely conflicting — telling those apart needs attempting a solve on the reduced system and checking whether the residual reaches zero, which is not implemented. A ConflictResolverPanel (the plan's SketchXpert-style feature) needs that distinction to propose correct candidate fixes and currently cannot be built correctly on top of diagnosis.rs as it stands — this is a ocs_gcs gap, not just a UI gap; flagged in §7 and reflected as its own stage in §6.

7. Open questions / risks (not resolved by reading code alone)

  1. XRecord round-trip fidelity untested. This design assumes writing a Chunk-typed XRecordEntry into a fresh Dictionary/XRecord pair and reading it back through save_as_version/document-open round-trips byte-for-byte. The writer paths exist and look complete (§1.4), but this was not exercised — no test was run. First implementation stage should be exactly this round-trip in isolation before any solver integration.
  2. Assoc2dConstraintGroup interop is explicitly deferred, not ruled out. If real-AutoCAD interop for constraint data becomes a product requirement later, mapping onto that object's node vocabulary is a separate, substantial design effort (reverse-engineered format, missing node kinds for half of ocs_gcs's constraint types) that this document does not attempt to solve.
  3. Copy/paste handle remapping table not located (§5.3) — needs a follow-up investigation into whichever command implements paste before Phase A's persistent constraints can be considered safe against copy/paste, or this should be explicitly scoped out of v1 with a documented limitation (pasted entities lose their constraints).
  4. Entity-deletion policy for dangling constraints is a product decision, not a technical one (§5.3) — silently drop vs. warn vs. block, needs a product call, not just an implementation.
  5. source_points() promotion scope unknown — this function (src/scene/dimension_assoc.rs, exact line not confirmed beyond the call site at :406-408) needs to be read in full to confirm it covers every entity type ocs_gcs constraints care about (line, circle, arc, ellipse variants) before relying on it as the shared sub-element enumeration — this investigation confirmed its existence and call convention, not its full entity-type coverage.
  6. Live-drag re-solve UX gap (§4.3) — constrained neighbors freeze during a drag and snap on release, rather than live-tracking like FreeCAD/Fusion 360/Onshape. Whether this is acceptable for v1 or a blocking UX regression against the plan's stated goal of matching those tools' feel is a product judgment call this document flags but does not make. A future stage could special-case a cheap, direct (non-ocs_gcs) local re-projection during drag frames (e.g. keep a coincident point glued without a full solve) as a partial mitigation, but that is meaningfully more work and is not in the staged plan below.
  7. adapt_to_bg/wire-recoloring hook not confirmed to exist under that name any more (§6.3) — the original plan file cites it; this investigation could not find it under that name in the current tree and recommends the overlay-glyph approach specifically to sidestep needing it.
  8. No conflicting-vs-redundant distinction in ocs_gcs::diagnosis (§6.4) — blocks a correct Phase C resolver until that solver-level gap is closed; this is tracked as its own implementation stage, not silently assumed away.
  9. Performance at scale untested. The locality arguments in §4.2 (only touched scopes rebuild) are architecturally sound but no profiling was done against a large, heavily-constrained drawing; the original port plan's fixture-based testing strategy doesn't cover this either.

8. Staged implementation plan

Each stage independently testable, matching how the rest of this port has proceeded (ocs_gcs itself untouched by any of this — all new code is app-side, except one bug fix in ocs_gcs::system::System::partition found while building stage 3 — see that stage's notes).

Progress (as of this session)

Stages 1–3 done and tested.

  • Stage 1: src/scene/sketch_constraints.rs — SketchRef, ConstraintKind (Coincident/Horizontal/Vertical/Parallel/Perpendicular/Equal/Distance/ Angle/Radius/Tangent), SketchConstraint, SketchScope (ModelSpace/Block), SketchConstraintSet. source_points() (src/scene/dimension_assoc.rs) promoted from private to pub(crate) — the one-line mechanical refactor §3.2 anticipated. 8 unit tests (cargo test --lib sketch_constraints), all passing.
  • Stage 2: tests/sketch_constraints_xrecord_roundtrip.rs — the XRecord round-trip spike, resolving open question 1. Simpler than the design doc assumed: CadDocument already has ensure_xrecord/xrecord/ xrecord_mut(owner, key) convenience methods (discovered by reading src/scene/centerline.rs's settings-persistence code, which uses exactly this pattern for CenterLineSettings) — no manual Dictionary/ named_objects_dict_handle graph-walking needed. Verified: a SketchConstraintSet serialized into a Chunk-typed XRecordEntry survives a full save/reload round-trip, for both DXF and DWG, for both a SketchScope::ModelSpace owner and a real SketchScope::Block(BlockRecord handle) owner (a bare, entity-less BlockRecord did not round-trip — the writer prunes it as unreferenced; a real block created via Scene::create_block_from_entities does). 3 tests, all passing.
  • Stage 3: refresh_sketch_constraints wired into bump_entities (src/scene/sketch_solve.rs), against an in-memory-only SketchConstraintSet per the plan (no save/load integration yet — that's still stage 4). Maps every ConstraintKind except Coincident's Y-half (folded in separately — see the module's doc comment) and Tangent (no settled ocs_gcs mapping yet) onto ocs_gcs primitives, rebuilds a fresh ocs_gcs::System from live document geometry on every trigger (§4.2), solves via solve_dl, writes back through record_undo_before/ get_entity_mut exactly as designed. 5 external integration tests (tests/sketch_constraints_solve.rs: Horizontal, Parallel, Distance, Coincident, and an unrelated-edit-doesn't-trigger check) + 1 internal unit test for the one-undo-step guarantee (needs pub(crate) undo APIs an external test crate can't reach).
    • Deviation from this doc: sketch_constraints: Vec<SketchConstraintSet> lives on Scene (via Scene::sketch_constraint_set/_mut, §2.1's field kept pub(crate) behind those two accessors so nothing can end up with two sets for one scope), not on DocumentTab as §2.1 originally proposed. Reason, discovered only by actually wiring the hook: bump_entities — which stage 3 must hook per §4.1 — is a Scene method, and Scene has no reference back to its owning DocumentTab. Putting the constraint sets on Scene (which already owns document: CadDocument, the only other thing this state is meaningfully co-located with) resolves that access problem directly rather than threading a callback or restructuring ownership.
    • Real ocs_gcs bug found and fixed: System::partition() built each SubSystem's free-parameter list from every parameter its constraints referenced, including ones marked driven (a dimensional constraint's target value, or any point a caller deliberately wants fixed) — it never filtered by the driven flag at all. Every existing working call site (crate::modules::draw::constrain's one-shot bridges, this stage's predecessor) sidesteps System entirely and calls SubSystem::new(clist, &explicit_free_list) directly, so this never got exercised until sketch_solve.rs became the first real System::partition() consumer. Symptom: a Distance constraint with both endpoints free solved to a wrong length (the driven target parameter itself was incorrectly free to move). Fixed with one filter (crates/ocs_gcs/src/system.rs), plus a regression test (partition_excludes_driven_params_from_each_subsystems_free_list) and a correction to the existing a_two_point_distance_sketch_builds_ partitions_and_round_trips test, which had silently encoded the buggy 5-free-param count as correct.

Stages 5–7 done and tested (skipping stage 4 — save/load — for now; the one-shot ribbon commands and DOF/glyphs were the more visible unlock and didn't depend on it).

  • Stage 5: Horizontal/Vertical/Parallel/Perpendicular/Equal (src/app/commands/draw.rs) and Distance/Angle (src/modules/draw/constrain/value.rs) all now add a persistent SketchConstraint instead of solving once and forgetting. New CmdResult::AddSketchConstraint { kind, refs, driving_param, label } (src/command.rs), handled once in command_driver.rs: resolves the current scope (DocumentTab::current_sketch_scope, new — model space, or the open block definition during BEDIT), adds the record, and triggers the first solve via the same bump_entities path any later edit uses. The old one-shot bridge functions (apply_horizontal et al., src/modules/draw/constrain/mod.rs) are deleted — nothing called them anymore once every dispatch arm moved over. Radius uses DCONSTRAINT on a circle (matching the one-shot version's existing dual-purpose UX; no separate ribbon button added). Coincident and Tangent are still not wired to any command (point-picking UI / no settled mapping, per the design doc's own scoping) — the constraint kind and solve path both already support Coincident, just nothing in the UI can author one yet. Live-verified: a slanted line (0,0)-(10,3), Horizontal applied via the ribbon — snapped level immediately, DOF: 3 appeared in the status bar, and a "—" glyph pill rendered at its midpoint (stage 7). Then dragged just the right endpoint via its grip to an arbitrary off-axis point — the left endpoint's Y moved to match it, keeping the line exactly level (both ends landed at y = −6.0407) with DOF: 3 unchanged throughout — confirming this is a real re-solved constraint, not a one-shot apply that happened to look similar.
  • Stage 6 (DOF badge): solve_scope (sketch_solve.rs) now also sums ocs_gcs::diagnosis::diagnose(...).dof across every independent partition in the scope and caches it on SketchConstraintSet::dof (new field, #[serde(skip)] — a derived cache, not persisted state). StatusBar::view takes a new sketch_dof: Option<usize> parameter and shows a plain (not yet user-hideable via StatusPill) "DOF: n" pill when the current scope has a constraint set at all; invisible otherwise, so an unconstrained drawing sees no new clutter. Bug found live-testing this stage: diagnose only sees params that appear in some constraint's own params() (via SubSystem's plist, per the stage-3 System::partition fix above) — a registered entity coordinate nothing constrains yet (e.g. a line's two X coordinates, after only a Horizontal constraint pins its Ys) was invisible to it entirely, undercounting DOF (a fresh Horizontal-only line showed "DOF: 1" live, not the correct 3). Fixed in solve_scope by adding back total_free − touched_free (every registered free param not referenced by any constraint is trivially 1 DOF each on its own), with a regression test (horizontal_... in tests/sketch_constraints_solve.rs now also asserts dof == Some(3)).
  • Stage 7 (constraint glyphs): extended the existing SelectionCanvas (src/ui/overlay.rs) with a constraint_glyphs: Vec<(Point, String)> field rather than adding a second canvas layer — same "always-on UI chrome" category as the grips/crosshair/UCS icon it already draws. Each enabled constraint gets one small pill (symbol, plus the driving value for a dimensional kind — sketch_constraints::glyph_label) at a computed world anchor (sketch_constraints::glyph_anchor: a point ref resolves through resolve_point; a whole-entity ref falls back to a line's midpoint or a circle's rightmost point), projected via the same relative-to-eye math the grips use (scene::pick::grip::project_rte, promoted from private to pub(crate) for this second caller). Model space only — paper space shows none, matching constraints not applying there. Visual only: no click-to-select or delete yet.

Stages 9, 10, and 12 done and tested (skipping stage 4 — save/load — still, for the same reason as stages 5-7 above; stage 9's "recommended" approach explicitly depends on it, so a scoped-down alternative was used — see below).

  • Stage 9 (constraint-set-edit undo): adding a persistent constraint is now itself undoable, not just the geometry moves it triggers. Deviation from this doc: §5.1(b)'s "live XRecord" approach (folding the constraint-set edit atomically into the same undo entry as the geometry delta) needs stage 4/save-load, which is still skipped this session — used §5.1(a)'s documented fallback instead: a new, independent HistorySnapshot::SketchConstraints(SketchConstraintsSnapshot) variant (src/app/document.rs) holding a whole-SketchConstraintSet before/after clone, pushed via a new push_sketch_constraints_history (src/app/history.rs) as its own entry adjacent to, not merged with, the Delta entry the triggering solve already produces via commit_undo_delta. CmdResult::AddSketchConstraint's handler (command_driver.rs) now captures the scope's constraint set before adding, and calls the new push after. Cost of the scoped-down approach: undoing a constraint-add takes two presses, not one — the first (topmost entry, pushed last) removes the constraint record and leaves the already-solved geometry in place; the second reverts the geometry itself. Redo replays symmetrically. 1 internal test (command_driver.rs::sketch_constraint_undo_tests:: adding_a_constraint_is_undoable_and_redoable_in_two_steps), passing, exercising exactly that two-step sequence in both directions.
  • Stage 10 (ocs_gcs::diagnosis conflict/redundant distinction): new RedundancyKind { Redundant, Conflicting } and classify_redundant(sub, store, redundant) -> Vec<(usize, RedundancyKind)> (crates/ocs_gcs/src/diagnosis.rs), answering the gap diagnose itself deliberately leaves open (per its module doc comment): for each row diagnose already flagged as linearly dependent, remove it, solve the reduced system from a cloned ParamStore (caller's real values untouched), and check whether the removed constraint's own error reaches ~0 there — redundant if so (the rest already implies it), conflicting if not (the rest disagrees with it). Kept as a separate, opt-in call rather than folded into diagnose (costs one extra solve per redundant row, which diagnose's "cheap enough for every edit" contract doesn't afford — but the common zero-redundant case never pays for it). 2 new tests (a duplicated Equal classifies Redundant; two different fixed Difference targets on the same pair classify Conflicting), both passing; not yet consumed by any UI (that's stage 11).
  • Stage 12 (copy/paste handle remapping + deletion policy): resolves both of §7's remaining open questions.
    • Open question 3 (which command owns the copy/paste handle-remap table): there isn't one shared table — Scene::copy_entities (src/scene/modify.rs, backing COPY/ARRAY/MIRROR) and OpenCADStudio::finalize_paste (command_driver.rs, clipboard paste) each already build their own local handle_map: FxHashMap<Handle, Handle> while duplicating entities. New Scene:: duplicate_sketch_constraints_for(&handle_map) (src/scene/sketch_constraints.rs) is called from both, right after their existing bump_entities: for every enabled constraint whose refs are all covered by the map, adds a remapped copy touching the new handles. A constraint straddling a duplicated and a non-duplicated entity is left alone (can't sensibly follow).
    • Open question 4 (entity-deletion policy): chose "silently drop", matching FreeCAD as this doc recommended. refresh_sketch_constraints (sketch_solve.rs) now runs a deletion pass first, before any resolve: for every ChangeKind::Removed handle, calls the already-existing SketchConstraintSet::remove_all_touching on every scope, unconditionally (not gated on a touched check), so a scope left empty by the deletion doesn't attempt a pointless resolve afterward.
    • 2 new external integration tests in tests/sketch_constraints_solve.rs (erasing a constrained line drops its constraint but leaves an unrelated one alone, and the survivor keeps solving; copying two Parallel-linked lines carries an independent copy of the constraint, verified by breaking the copy's parallelism and confirming its own constraint re-levels it while the original pair is untouched), both passing.

Stages 8 and 11 done and tested (stage 4/save-load still skipped, same reasons as above).

  • Stage 8 (Phase B live inference): drawing a LINE whose endpoint already coincides with an existing entity's point in the current scope now auto-adds a Coincident constraint. New sketch_constraints::infer_coincident_refs(document, scope, new_handle, new_entity) (src/scene/sketch_constraints.rs) checks every one of the new entity's source_points() against every other entity's in the same scope for near-exact world-space coincidence (1e-6 epsilon), returning a (new, existing) SketchRef pair per match. Wired into command_driver.rs's new infer_and_add_coincident_constraints, called from the CmdResult::CommitEntity dispatch — scoped to the active command being named "LINE" specifically (not every CommitEntity source; FILLET/ OFFSET/MIRROR/etc. also route Lines through the same dispatch and shouldn't silently acquire constraints nobody asked for). Suppressible by holding Shift.
    • Deviations from this doc: §6.2 suggested reusing OSNAP's screen-space tolerance/query; this instead checks tight world-space coincidence, since the natural hook point (CmdResult::CommitEntity's dispatch) has no cursor pixel position or camera by the time an entity becomes a document write — only its final world coordinates. If OSNAP's endpoint mode already grabbed the point while the user was drawing, the coordinates already match to floating-point precision, so this reproduces the same result without re-deriving OSNAP's screen-space math or threading camera state into this dispatch site. Separately, §6.2 proposed a new CadCommand::set_modifier mechanism for the suppression key; turned out unnecessary — command_driver.rs already reads self.shift_down directly at this exact dispatch site (the app-level field the existing Ctrl/Shift push already tracks), so no new per-command plumbing was needed.
    • Mirrors CmdResult::AddSketchConstraint's undo shape (stage 9): the add-and-solve is bracketed in its own begin_undo/commit_undo_delta, then the constraint-set edit is pushed as the separate, adjacent SketchConstraints entry.
    • 2 new internal tests (drawing onto an existing endpoint infers a Coincident referencing the pre-existing line; Shift suppresses it entirely), both passing.
  • Stage 11 (Phase C conflict resolver): solve_scope (sketch_solve.rs) now also tracks which system-level ocs_gcs constraint came from which SketchConstraint (an owner: Vec<(Rc<dyn Constraint>, ConstraintId)> built alongside registration — a SubSystem's redundant-row indices are local to that partition's own list, not set.constraints' indices, and Coincident contributes two system-level constraints for one SketchConstraint). When a partition's diagnose flags any redundant rows, classify_redundant (stage 10) is called on just that partition and each result resolved back to a ConstraintId via Rc::ptr_eq against the owner list, cached on a new SketchConstraintSet::conflicts: Vec<(ConstraintId, RedundancyKind)> field (#[serde(skip)], like dof). A new status-bar pill ("⚠ n conflicting", src/ui/statusbar/mod.rs) appears only when the current scope's conflicts is non-empty; clicking it (Message:: ResolveOneSketchConflict → OpenCADStudio::resolve_one_sketch_conflict, command_driver.rs) removes the first flagged constraint and re-solves, using the same undo bracket as stage 8/9's handlers.
    • Deviation from this doc: §6.4's full ConflictResolverPanel — a window listing every flagged constraint by name with a cyclable live preview of each candidate fix before committing — is real, standalone UI work (new window state, Message plumbing for open/close/select/ preview/commit) that this pass did not have live-UI-testing access to verify: the desktop session's app focus was blocked for the whole of this stage by an unrelated stuck System Settings window on a second monitor (a recurrence of the same class of issue noted during stages 5-7's live verification). What's built instead is bounded but real: one click removes one flagged constraint, so a user with several conflicts clicks repeatedly and watches the DOF/conflict count drop — same end effect as the panel's "remove and re-solve" action, minus the browsing/naming/preview UI. The full panel remains open follow-up work.
    • 2 new external integration tests (tests/sketch_constraints_solve.rs: a duplicated Horizontal constraint classifies Redundant and resolves to one of the two ids; two conflicting Distance targets on the same pair classify Conflicting) + 1 internal test (resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoable in command_driver.rs, also confirming the removal is undoable), all passing.

Full regression check after stages 8/9/10/11/12: cargo test --workspace, 660 passed / 1 failed (scene::text::lff::tests::fonts_parse_and_resolve, a pre-existing Turkish-glyph-fallback test wholly unrelated to this work — src/scene/text/lff.rs untouched by any stage in this doc) / 13 ignored.

Stages 8 and 11 live-verified in a release build (cargo build --release --bin OpenCADStudio, run from a scratch copy of the app bundle so /Applications's installed copy was never touched) once the stuck-focus environment issue cleared. Stage 8: drew two separate LINE segments whose shared endpoint coincided exactly — a "≡" glyph appeared at the junction automatically and the DOF badge read 6 (8 raw coordinates − 2 for the inferred constraint); dragging one line's endpoint grip pulled the other line's endpoint along with it, confirming the inferred constraint re-solves live rather than just having matched once. Stage 11: applied Horizontal to the same line twice via the ribbon — a "⚠ 1 conflicting" pill appeared next to the DOF badge; clicking it removed the duplicate and the pill vanished, DOF unchanged at 5. Both matched the automated tests' expectations exactly.

Stage 4 (save/load integration) done and tested. Built the "lazy" model §5.2 recommends as the first step: src/scene/sketch_persist.rs, new. No new HistorySnapshot variant or resident-XRecord machinery — constraint sets stay exactly where stage 3 put them (Scene::sketch_constraints, in-memory only during editing) and are materialized into document.objects only right before a save, read back right after a load.

  • Scene::materialize_sketch_constraints_for_save: for each non-empty SketchConstraintSet, resolves its scope's owner handle (SketchScope::owner_handle), ensure_xrecords a "OCS_SKETCH_ CONSTRAINTS"-keyed record there, and overwrites its entries with one version-prefixed Chunk (bincode::serialize, prefixed with a FORMAT_VERSION: u8 — §5.2's own call-out: a future schema change bumps this, and decode rejects a mismatched version rather than misreading bytes, though the migration path itself is still undesigned, per §7). Empty sets are skipped, so an unconstrained drawing gains no persisted bookkeeping. Called from OpenCADStudio::prepare_native_save (covers every native save trigger: Save, Save As, autosave, recovery-save) and from the wasm save path's own manual prep block (src/app/update/ file.rs) — both confirmed to be the actual save entry points by tracing every caller, not assumed.
  • Scene::load_sketch_constraints_from_document: clears sketch_constraints, then checks every BlockRecord in document. block_records (model space, paper space, and every named block definition — the table already enumerates all of them, so no separate "which scopes exist" bookkeeping was needed) for that XRecord key, decoding and pushing whatever it finds. Called from OpenCADStudio::on_file_opened (native/web open) and the automation "open" op (src/app/automation.rs) — the two places a CadDocument actually gets installed into a tab's Scene; the automation "new" op now also clears sketch_constraints for the same reason "open" needs the load call (a reused tab slot must not keep a previous document's constraints).
  • Deliberate scope-down, not yet done: no "resolve once on load" step. A freshly opened file's SketchConstraintSet.dof/.conflicts stay at their post-deserialize defaults (None/empty) until the user's next edit touches that scope — already anticipated by stage 6's own dof field doc comment, not a new gap this stage introduces. Considered forcing an initial resolve right after load (would populate the DOF badge immediately and self-heal any float drift from serialization) but on_file_opened installs a DerivedCaches bundle computed on a background thread before this hook runs — mutating entity geometry via a resolve here could desync from tessellation/derived state that bundle already reflects, and this pass didn't have budget to verify that interaction is safe. Left as None/empty rather than risk it; the §5.2 "live" model (constraint sets resident in document.objects at all times, closing this and the stage-9 undo-shape gap together) remains the documented next step if this is worth revisiting.
  • 6 new internal tests (src/scene/sketch_persist.rs): materialize+reload agreement for both a ModelSpace and a SketchScope::Block set, an empty set not persisting, format-version rejection, and — going beyond the in-memory-only checks — two real save_to_bytes/load_bytes round trips (DXF and DWG) through the actual io primitives the app's open/save path uses, not just materialize and load agreeing with each other on the same document.

Full regression check after stage 4: cargo test --workspace, 666 passed / 1 failed (the same pre-existing, unrelated font test noted above) / 13 ignored. Not live-verified in the running app this pass (no UI change to click through — this stage is data-layer only, and its correctness is a byte-round-trip property the automated tests already cover directly).

Every stage in the §8 plan below is now built and tested. Three documented scope-downs remain against this doc's original recommendations: stage 9's two-press undo, stage 11's one-at-a-time resolver pill, and stage 4's no-resolve-on-load — all explained at their own stage above.

Post-plan follow-ups: Coincident/Tangent manual UI, live-drag re-solve, color coding

Three items from the earlier "older open items" list, done on user request after all twelve staged-plan stages landed.

  • Tangent mapping + manual UI: sketch_solve.rs's build_constraint now maps ConstraintKind::Tangent for Line-Circle (ocs_gcs::constraints:: circle_arc::C2LDistance, with a driven zero-distance target and internal: false — this makes its target exactly the circle's own radius, i.e. plain tangency) and Circle-Circle (TangentCircumf). ccw/internal are picked from the pair's current geometry (which side of the line the circle already sits on; whether the circles are already nested) so the first solve doesn't have to cross the sign-flip singularity between the two tangency configurations. Line-Line and anything involving an Arc build nothing (Arc isn't a supported EntityGeom yet, matching that type's own documented scope) — skipped, not panicked, same contract every other unbuildable constraint gets. Wired to a new TCONSTRAINT ribbon button (src/modules/draw/constrain/tools.rs), reusing the existing two-whole-entity-selection dispatch (src/app/commands/draw.rs) Parallel/ Perpendicular/Equal already use. 2 new integration tests (line-circle and circle-circle, both converging to within 1e-6 of exact tangency from a deliberately-not-tangent start).
  • Coincident manual UI: new CoincidentConstraintCommand (src/modules/draw/constrain/coincident.rs, CCONSTRAINT) — unlike the rest of this module, Coincident addresses a point on an entity, not the whole thing, so it needs two point picks instead of a selection. A CadCommand has no document access (same constraint value.rs's DCONSTRAINT/ACONSTRAINT already work around), so it only accumulates the two raw points and hands them back as a new CmdResult:: AddCoincidentConstraint { point_a, point_b, label } for the host to resolve — mirroring ReassociateCenterMark's existing split. New sketch_constraints::nearest_sketch_point(document, scope, world_point, exclude) finds the closest addressable point (an ordinary source_points() index, or a circle/arc center) within the same world-space epsilon stage 8's infer_coincident_refs already established — same trust that the point arriving here is already OSNAP-snapped, for the same reason (no camera/pixel radius available this far from the click). A pick that doesn't resolve reports a command-line error ("enable an Endpoint/Center object snap and try again") rather than silently constraining the wrong thing or the nearest-anything. 2 new internal tests (a successful two-point resolve referencing both lines; a missed pick adding nothing).
  • Color coding (§6.3's own recommendation — glyph/badge color, not wire recoloring): a constraint's glyph pill now renders in the theme's danger palette instead of primary when SketchConstraintSet.conflicts (stage 10/11) flags it as redundant/conflicting (src/ui/overlay.rs/src/app/view/mod.rs, constraint_glyphs tuple grew an is_conflicting: bool). Separately, the DOF status-bar pill now uses a new success_pill (theme's success palette) instead of the plain status_pill when dof == 0 — "fully constrained" is visible at a glance, matching the UX research bullet in §1 ("a simple color-coded constraint state"). No render_graph.rs/entity-recoloring changes, per this doc's own recommendation to avoid that larger, riskier surface.
  • Live-drag re-solve (§7 open question 6 — the largest of the three): constrained neighbors previously froze during a grip drag and only snapped into place on release. Investigation found the assumption behind that open question ("the document is unmutated during a live-preview drag") was wrong for this codebase: Scene::apply_grip already writes the dragged handle's geometry straight into self.document on every mouse-move frame — the "preview" system (set_preview_wires) is a tessellated-wire rendering optimization, not a way of keeping the document clean during the drag. That meant the existing solve_scope/ refresh_sketch_constraints machinery could run mid-drag with no architectural mismatch; the missing piece was purely that nothing called it per-frame (only once, at grip release, via bump_entities(&changes) in viewport.rs's release handler).
    • New Scene::solve_sketch_constraints_preview(&self, touched: &[Handle]) -> Vec<(Handle, EntityType)> (sketch_solve.rs) — a lighter sibling of refresh_sketch_constraints: same rebuild-and-solve core, but reports what moved instead of writing it back, and skips record_undo_before entirely (a grip drag tracks its before/after through its own grip_originals/grip_preview_handles arrays, committed as one push_entity_group_history group on release — not through the recording session refresh_sketch_constraints otherwise participates in; calling record_undo_before here would assume a recording context that doesn't exist for this call path).
    • Wired into the per-mouse-move drag update in src/app/update/ viewport.rs, right after Scene::apply_grip writes the directly-dragged handle's new position, before this frame's mesh/hatch/wire-preview refresh. A constraint-solved neighbor's first touch this gesture captures its original state into grip_originals (so the eventual release-time undo group covers it correctly) and hides it from the resident tessellation (preview_hidden), then every frame folds it into this frame's edited_handles so the rest of the function's existing preview/mesh/hatch refresh already covers it for free — no changes needed to that downstream code.
    • Cheap no-op when there are no sketch constraints at all (the common case) or nothing touched resolves to a constrained handle. Per this doc's own §4.2 architecture this still does a full rebuild-and-solve per touched scope on every call — i.e. every mouse-move frame during a drag that touches constrained geometry; §7 open question 9 ("performance at scale untested") already flags this as an accepted, unprofiled risk, not a new one.
    • 1 new internal test proving the preview solve reports the correct result without mutating the document at all.

Full regression check: cargo test --workspace, 669 passed / 1 failed (same pre-existing unrelated font test) / 13 ignored.

Live-verified in a release build (same scratch-copy-of-the-app-bundle technique as stages 8/11): live-drag re-solve was the main target, since it's the one genuinely new architectural mechanism here, and it's conclusively confirmed — dragging one line's shared-endpoint grip visibly pulled a second, separately-constrained line's endpoint along with it during the drag (captured via a manual mouse-down / mouse-move / screenshot / mouse-up sequence, not just before-and-after), and undo/redo correctly treated the whole gesture (including the constraint-followed neighbor) as one step. Tangent was confirmed live to apply via the ribbon, correctly update the DOF badge, and undo/redo cleanly; live pixel-checking the exact resulting tangency point on a deliberately extreme (very far-apart) test configuration was inconclusive from screenshots alone, but the underlying formula is independently proven by the automated tests above at 1e-6 tolerance from a similarly-unfavorable starting configuration. Coincident's command dispatch, prompting, and missed-pick error path were all confirmed live; the success path needs a real OSNAP-precision click this session's synthetic input couldn't reliably reproduce (the epsilon is deliberately tight — see nearest_sketch_point's doc comment — well below what manual screenshot-and-click pixel estimation can hit), so it's verified by the automated tests only. Color coding was not separately live-clicked this pass (small, low-risk code reusing an established theme-palette pattern already used elsewhere in this codebase).

  1. SketchRef/SketchConstraint/SketchConstraintSet data types + promote/confirm source_points()-equivalent sub-element enumeration (§3.2, resolves open question 5). Pure data + unit tests, no document integration yet.
  2. XRecord round-trip spike (resolves open question 1): write a throwaway blob into a fresh Dictionary/XRecord under named_objects_dict_handle, save via save_as_version, reload, confirm byte-identical readback. Gate everything after this stage on it passing.
  3. refresh_sketch_constraints wired into bump_entities (§4.1) against an in-memory-only SketchConstraintSet (no save/load yet — populate it only via a test harness or a temporary debug command). Verify: one edit to a constrained entity moves its constrained neighbors, produces one undo step, doesn't infinite-loop, doesn't fire on unrelated edits.
  4. Save/load integration (§5.2, "lazy" model): serialize on save, deserialize on open, using stage 2's confirmed round-trip. Constraint-set-edit undo (§5.1) can stay a known gap at this stage (document it as a TODO, not silently broken — e.g. adding a constraint without an accompanying geometry change simply isn't undoable yet).
  5. Phase A commands: persistent Coincident/Horizontal/Vertical/ Parallel/Perpendicular/Equal/Distance/Angle/Radius/Tangent, replacing the one-shot constrain::apply_* call sites in src/app/commands/draw.rs with add_constraint + bump_entities (§6.1). Sub-entity point picking UI for Coincident/Distance/Angle/Radius/Tangent is new work here, not reused from the one-shot commands (which never needed it).
  6. DOF badge (§6.3) — cheap, additive, ships as soon as stage 3's solve produces a Diagnosis to read.
  7. Constraint glyphs overlay (§6.3).
  8. Phase B live inference (§6.2), including the modifier-key escape hatch.
  9. Constraint-set-edit undo migration to the "live" XRecord model (§5.1(b)/§5.2), closing stage 4's known gap.
  10. ocs_gcs::diagnosis conflicting-vs-redundant distinction (open question 8) — a ocs_gcs-side change, tested the same way the rest of that crate is (cargo test -p ocs_gcs), independent of everything else in this plan.
  11. Phase C conflict resolver UI, gated on stage 10.
  12. Copy/paste handle remapping (open question 3) and deletion policy (open question 4) — investigate and resolve before calling persistent constraints "done"; both can technically ship without this (with documented limitations) if timeline pressure requires it, but should not be silently forgotten.