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>
70 KiB
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 anUndoRecordingthat captures, per handle, the first-touch before-image (record_undo_before,:2499, no-op if not recording — callers guard withis_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 symmetricDeltaSnapshot(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-720doc comment). -
Scene::bump_entities(&mut self, changes: &[(Handle, ChangeKind)])(:2612) is the single choke point every mutation goes through: it bumpsgeometry_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
changesslice, finds document objects that depend on the changed handles, recomputes their derived geometry, and appends its own(Handle, ChangeKind::Modified)entries to the samechangesvec (src/scene/mod.rs:2624-2643) rather than callingbump_entitiesagain — so there is no re-entrant bump and no infinite loop. The centerline/centermark passes additionally callis_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
SketchConstraintSetre-solve needs, and the precedent already establishes the exact call contract to match. -
changes_touch_block_definition(:2698) and thelayout_blocks/BlockRecordscan 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 onebr_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:
- Grip identity.
GripDef(src/scene/model/object.rs:105) haspub id: usize, documented as "Object-local identifier (stable index, unique per object instance)". AGripEdit/GripTarget(src/scene/pick/grip.rs:29-56) addresses a drag target as(handle: Handle, grip_id: usize). For aLine, grip 0/1 are (by established convention in every entity's grip producer) the endpoints. - 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) hasxrefs: 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 intosource_points(entity)— an orderedVec<Vector3>of named points per entity type (line start/end, etc.), i.e. exactly the GsMarker-indexed point-list schemeocs_gcsconstraint 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::AssociativewithAssociativeData::ConstraintGroup(Assoc2dConstraintGroup)(.../objects/associative.rs:74,:1069-1091) is AutoCAD's ownAcDbAssoc2dConstraintGroup— 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 withgeometry_dependency: Handle+geometry_node_id: i32— but it does not have explicit node kinds for Coincident, Horizontal, Vertical, Equal, Perpendicular, or Tangent, and itsconnections: 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 ~30ocs_gcsconstraint 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/XRecordValueTypeincludes aChunk(binary blob) variant (:57-82). XRecords hang off aDictionary(.../objects/mod.rs:136) viaxdictionary_handle, and the document's root isCadDocument.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:
- Filters
changesto handles that are referenced by someSketchConstraint::refsin someSketchConstraintSeton this tab (aHandle -> Vec<(scope, ConstraintId)>reverse index, rebuilt lazily and invalidated the same waySceneDependencyIndexalready is —invalidate_dependency_index,src/scene/mod.rs:2582/:9882— same pattern, separate cache). - For each affected scope, rebuilds a fresh
ocs_gcs::Systemfrom that scope's current entity coordinates +SketchConstraintrecords (§4.2), solves, and — for every parameter that moved — callsis_recording_undo()+record_undo_before()(matchingcenterline.rs:338-340) thendocument.get_entity_mut(...)to write the solved coordinates back, and collects(Handle, ChangeKind::Modified)for the return vec. - Returns that vec;
bump_entitiesfolds it intochangesexactly 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:
SketchConstraintSetalso exists as a residentObjectType::XRecordindocument.objectsat 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_versionjust writes what's there). Cost: every constraint add/remove/re-solve re-serializes the whole scope's blob into the residentXRecord.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:
SketchConstraintSetlives only inDocumentTab(§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 dedicatedHistorySnapshotvariant) since there's no resident object to hang the existing delta-undo path off of, and requires the save path to reach intoDocumentTab-level state (currentlysave_as_versiontakes&CadDocumentonly, persrc/io/mod.rs:1546— would need a new call site that also receives&[SketchConstraintSet], or a pre-save step that materializes them intodocument.objectstransiently before handing off tosave_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
SketchConstraintis erased, the constraint becomes dangling.refresh_sketch_constraints(§4.1) must detectChangeKind::Removedfor 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 fromallocate_handle), so constraints between two copied entities need theirSketchRef.entityhandles remapped through whatever handle-mapping table the paste command already builds for other cross-entity references (e.g. block INSERT membership, dimension associativity —AssociativeDatais 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 viaiced:: 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 readingDocumentTab::sketch_constraintsand 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 anadapt_to_bginsrc/scene/project.rs— this investigation could not locateadapt_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, norender_graph.rschanges, 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.rshasStatusBar::view(:66) taking a data struct (StatusMenuData,:41) — a small addition there, summingocs_gcs::diagnosis::diagnose(...).dofacross all of the active tab'sSketchConstraintSets (only the ones touching the current space) is a cheap, self-contained addition.diagnoseis 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 afterrefresh_sketch_constraints' solve in §4.1, cache theDiagnosison theSketchConstraintSetalongside 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)
- XRecord round-trip fidelity untested. This design assumes writing a
Chunk-typedXRecordEntryinto a freshDictionary/XRecordpair and reading it back throughsave_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. Assoc2dConstraintGroupinterop 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 ofocs_gcs's constraint types) that this document does not attempt to solve.- 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).
- 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.
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 typeocs_gcsconstraints 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.- 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. 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.- 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. - 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 topub(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:CadDocumentalready hasensure_xrecord/xrecord/xrecord_mut(owner, key)convenience methods (discovered by readingsrc/scene/centerline.rs's settings-persistence code, which uses exactly this pattern forCenterLineSettings) — no manualDictionary/named_objects_dict_handlegraph-walking needed. Verified: aSketchConstraintSetserialized into aChunk-typedXRecordEntrysurvives a full save/reload round-trip, for both DXF and DWG, for both aSketchScope::ModelSpaceowner and a realSketchScope::Block(BlockRecord handle)owner (a bare, entity-lessBlockRecorddid not round-trip — the writer prunes it as unreferenced; a real block created viaScene::create_block_from_entitiesdoes). 3 tests, all passing. - Stage 3:
refresh_sketch_constraintswired intobump_entities(src/scene/sketch_solve.rs), against an in-memory-onlySketchConstraintSetper the plan (no save/load integration yet — that's still stage 4). Maps everyConstraintKindexceptCoincident's Y-half (folded in separately — see the module's doc comment) andTangent(no settledocs_gcsmapping yet) ontoocs_gcsprimitives, rebuilds a freshocs_gcs::Systemfrom live document geometry on every trigger (§4.2), solves viasolve_dl, writes back throughrecord_undo_before/get_entity_mutexactly 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 (needspub(crate)undo APIs an external test crate can't reach).- Deviation from this doc:
sketch_constraints: Vec<SketchConstraintSet>lives onScene(viaScene::sketch_constraint_set/_mut, §2.1's field keptpub(crate)behind those two accessors so nothing can end up with two sets for one scope), not onDocumentTabas §2.1 originally proposed. Reason, discovered only by actually wiring the hook:bump_entities— which stage 3 must hook per §4.1 — is aScenemethod, andScenehas no reference back to its owningDocumentTab. Putting the constraint sets onScene(which already ownsdocument: 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_gcsbug found and fixed:System::partition()built eachSubSystem's free-parameter list from every parameter its constraints referenced, including ones markeddriven(a dimensional constraint's target value, or any point a caller deliberately wants fixed) — it never filtered by thedrivenflag at all. Every existing working call site (crate::modules::draw::constrain's one-shot bridges, this stage's predecessor) sidestepsSystementirely and callsSubSystem::new(clist, &explicit_free_list)directly, so this never got exercised untilsketch_solve.rsbecame the first realSystem::partition()consumer. Symptom: aDistanceconstraint 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 existinga_two_point_distance_sketch_builds_ partitions_and_round_tripstest, which had silently encoded the buggy 5-free-param count as correct.
- Deviation from this doc:
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 persistentSketchConstraintinstead of solving once and forgetting. NewCmdResult::AddSketchConstraint { kind, refs, driving_param, label }(src/command.rs), handled once incommand_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 samebump_entitiespath any later edit uses. The old one-shot bridge functions (apply_horizontalet al.,src/modules/draw/constrain/mod.rs) are deleted — nothing called them anymore once every dispatch arm moved over. Radius usesDCONSTRAINTon 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: 3appeared 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) withDOF: 3unchanged 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 sumsocs_gcs::diagnosis::diagnose(...).dofacross every independent partition in the scope and caches it onSketchConstraintSet::dof(new field,#[serde(skip)]— a derived cache, not persisted state).StatusBar::viewtakes a newsketch_dof: Option<usize>parameter and shows a plain (not yet user-hideable viaStatusPill) "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:diagnoseonly sees params that appear in some constraint's ownparams()(viaSubSystem's plist, per the stage-3System::partitionfix 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 insolve_scopeby adding backtotal_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_...intests/sketch_constraints_solve.rsnow also assertsdof == Some(3)). - Stage 7 (constraint glyphs): extended the existing
SelectionCanvas(src/ui/overlay.rs) with aconstraint_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 throughresolve_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 topub(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-SketchConstraintSetbefore/after clone, pushed via a newpush_sketch_constraints_history(src/app/history.rs) as its own entry adjacent to, not merged with, theDeltaentry the triggering solve already produces viacommit_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::diagnosisconflict/redundant distinction): newRedundancyKind { Redundant, Conflicting }andclassify_redundant(sub, store, redundant) -> Vec<(usize, RedundancyKind)>(crates/ocs_gcs/src/diagnosis.rs), answering the gapdiagnoseitself deliberately leaves open (per its module doc comment): for each rowdiagnosealready flagged as linearly dependent, remove it, solve the reduced system from a clonedParamStore(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 intodiagnose(costs one extra solve per redundant row, whichdiagnose's "cheap enough for every edit" contract doesn't afford — but the common zero-redundant case never pays for it). 2 new tests (a duplicatedEqualclassifiesRedundant; two different fixedDifferencetargets on the same pair classifyConflicting), 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) andOpenCADStudio::finalize_paste(command_driver.rs, clipboard paste) each already build their own localhandle_map: FxHashMap<Handle, Handle>while duplicating entities. NewScene:: duplicate_sketch_constraints_for(&handle_map)(src/scene/sketch_constraints.rs) is called from both, right after their existingbump_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 everyChangeKind::Removedhandle, calls the already-existingSketchConstraintSet::remove_all_touchingon every scope, unconditionally (not gated on atouchedcheck), 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.
- Open question 3 (which command owns the copy/paste handle-remap
table): there isn't one shared table —
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
LINEwhose endpoint already coincides with an existing entity's point in the current scope now auto-adds a Coincident constraint. Newsketch_constraints::infer_coincident_refs(document, scope, new_handle, new_entity)(src/scene/sketch_constraints.rs) checks every one of the new entity'ssource_points()against every other entity's in the same scope for near-exact world-space coincidence (1e-6 epsilon), returning a(new, existing)SketchRefpair per match. Wired intocommand_driver.rs's newinfer_and_add_coincident_constraints, called from theCmdResult::CommitEntitydispatch — scoped to the active command being named"LINE"specifically (not everyCommitEntitysource; 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 newCadCommand::set_modifiermechanism for the suppression key; turned out unnecessary —command_driver.rsalready readsself.shift_downdirectly 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 ownbegin_undo/commit_undo_delta, then the constraint-set edit is pushed as the separate, adjacentSketchConstraintsentry. - 2 new internal tests (drawing onto an existing endpoint infers a Coincident referencing the pre-existing line; Shift suppresses it entirely), both passing.
- 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 (
- Stage 11 (Phase C conflict resolver):
solve_scope(sketch_solve.rs) now also tracks which system-levelocs_gcsconstraint came from whichSketchConstraint(anowner: Vec<(Rc<dyn Constraint>, ConstraintId)>built alongside registration — aSubSystem's redundant-row indices are local to that partition's own list, notset.constraints' indices, and Coincident contributes two system-level constraints for oneSketchConstraint). When a partition'sdiagnoseflags any redundant rows,classify_redundant(stage 10) is called on just that partition and each result resolved back to aConstraintIdviaRc::ptr_eqagainst the owner list, cached on a newSketchConstraintSet::conflicts: Vec<(ConstraintId, RedundancyKind)>field (#[serde(skip)], likedof). A new status-bar pill ("⚠ n conflicting",src/ui/statusbar/mod.rs) appears only when the current scope'sconflictsis 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,Messageplumbing 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 classifiesRedundantand resolves to one of the two ids; two conflictingDistancetargets on the same pair classifyConflicting) + 1 internal test (resolve_one_sketch_conflict_removes_a_flagged_constraint_and_is_undoableincommand_driver.rs, also confirming the removal is undoable), all passing.
- Deviation from this doc: §6.4's full
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-emptySketchConstraintSet, resolves its scope's owner handle (SketchScope::owner_handle),ensure_xrecords a"OCS_SKETCH_ CONSTRAINTS"-keyed record there, and overwrites itsentrieswith one version-prefixedChunk(bincode::serialize, prefixed with aFORMAT_VERSION: u8— §5.2's own call-out: a future schema change bumps this, anddecoderejects 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 fromOpenCADStudio::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: clearssketch_constraints, then checks everyBlockRecordindocument. 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 fromOpenCADStudio::on_file_opened(native/web open) and the automation"open"op (src/app/automation.rs) — the two places aCadDocumentactually gets installed into a tab'sScene; the automation"new"op now also clearssketch_constraintsfor 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/.conflictsstay at their post-deserialize defaults (None/empty) until the user's next edit touches that scope — already anticipated by stage 6's owndoffield 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) buton_file_openedinstalls aDerivedCachesbundle 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 asNone/empty rather than risk it; the §5.2 "live" model (constraint sets resident indocument.objectsat 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 aSketchScope::Blockset, an empty set not persisting, format-version rejection, and — going beyond the in-memory-only checks — two realsave_to_bytes/load_bytesround trips (DXF and DWG) through the actualioprimitives the app's open/save path uses, not justmaterializeandloadagreeing 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'sbuild_constraintnow mapsConstraintKind::Tangentfor Line-Circle (ocs_gcs::constraints:: circle_arc::C2LDistance, with a driven zero-distance target andinternal: false— this makes its target exactly the circle's own radius, i.e. plain tangency) and Circle-Circle (TangentCircumf).ccw/internalare 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 supportedEntityGeomyet, matching that type's own documented scope) — skipped, not panicked, same contract every other unbuildable constraint gets. Wired to a newTCONSTRAINTribbon 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. ACadCommandhas no document access (same constraintvalue.rs'sDCONSTRAINT/ACONSTRAINTalready work around), so it only accumulates the two raw points and hands them back as a newCmdResult:: AddCoincidentConstraint { point_a, point_b, label }for the host to resolve — mirroringReassociateCenterMark's existing split. Newsketch_constraints::nearest_sketch_point(document, scope, world_point, exclude)finds the closest addressable point (an ordinarysource_points()index, or a circle/arc center) within the same world-space epsilon stage 8'sinfer_coincident_refsalready 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
dangerpalette instead ofprimarywhenSketchConstraintSet.conflicts(stage 10/11) flags it as redundant/conflicting (src/ui/overlay.rs/src/app/view/mod.rs,constraint_glyphstuple grew anis_conflicting: bool). Separately, the DOF status-bar pill now uses a newsuccess_pill(theme'ssuccesspalette) instead of the plainstatus_pillwhendof == 0— "fully constrained" is visible at a glance, matching the UX research bullet in §1 ("a simple color-coded constraint state"). Norender_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_gripalready writes the dragged handle's geometry straight intoself.documenton 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 existingsolve_scope/refresh_sketch_constraintsmachinery could run mid-drag with no architectural mismatch; the missing piece was purely that nothing called it per-frame (only once, at grip release, viabump_entities(&changes)inviewport.rs's release handler).- New
Scene::solve_sketch_constraints_preview(&self, touched: &[Handle]) -> Vec<(Handle, EntityType)>(sketch_solve.rs) — a lighter sibling ofrefresh_sketch_constraints: same rebuild-and-solve core, but reports what moved instead of writing it back, and skipsrecord_undo_beforeentirely (a grip drag tracks its before/after through its owngrip_originals/grip_preview_handlesarrays, committed as onepush_entity_group_historygroup on release — not through the recording sessionrefresh_sketch_constraintsotherwise participates in; callingrecord_undo_beforehere 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 afterScene::apply_gripwrites 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 intogrip_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'sedited_handlesso 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.
- New
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).
SketchRef/SketchConstraint/SketchConstraintSetdata types + promote/confirmsource_points()-equivalent sub-element enumeration (§3.2, resolves open question 5). Pure data + unit tests, no document integration yet.- XRecord round-trip spike (resolves open question 1): write a
throwaway blob into a fresh
Dictionary/XRecordundernamed_objects_dict_handle, save viasave_as_version, reload, confirm byte-identical readback. Gate everything after this stage on it passing. refresh_sketch_constraintswired intobump_entities(§4.1) against an in-memory-onlySketchConstraintSet(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.- 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).
- Phase A commands: persistent Coincident/Horizontal/Vertical/
Parallel/Perpendicular/Equal/Distance/Angle/Radius/Tangent, replacing the
one-shot
constrain::apply_*call sites insrc/app/commands/draw.rswithadd_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). - DOF badge (§6.3) — cheap, additive, ships as soon as stage 3's solve
produces a
Diagnosisto read. - Constraint glyphs overlay (§6.3).
- Phase B live inference (§6.2), including the modifier-key escape hatch.
- Constraint-set-edit undo migration to the "live" XRecord model (§5.1(b)/§5.2), closing stage 4's known gap.
ocs_gcs::diagnosisconflicting-vs-redundant distinction (open question 8) — aocs_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.- Phase C conflict resolver UI, gated on stage 10.
- 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.