Harden parametric sketch constraints

This commit is contained in:
Hakan Seven committed 2026-09-11 09:45:57 +03:00
1 parent 41c07e9858
commit 9b58ca8a93
101 files changed
+3128 -13767

No files matched your search

-148
View File
@@ -1,148 +0,0 @@
# Backlog — deferred items, known gaps, and scope-downs
A consolidated list of things noticed but deliberately not fixed/built, so
they aren't lost between sessions. Pulled from `docs/
parametric_system_design.md`'s own "open questions" log, the `ocs_gcs` port
plan, and things found while doing unrelated work this session. Not a bug
tracker for the whole app — scoped to what surfaced during the parametric
constraint system project and its surrounding investigation.
## Parametric constraint system — deliberate scope-downs (documented, not accidental)
1. **Stage 9 undo is two presses, not one.** Adding a persistent constraint
and the geometry move it triggers are two separate undo/redo entries
(`HistorySnapshot::SketchConstraints` pushed adjacent to, not merged into,
the geometry `Delta`). The design doc's recommended §5.1(b) "live
XRecord" approach would fix this but depends on stage 4 (save/load),
which itself was scoped down (next item) — revisit both together if this
is ever worth fixing.
2. **Stage 4 does not resolve on load.** Opening a file with saved
constraints restores them but does not immediately re-solve — the DOF
badge and any conflict state only appear after the next edit that touches
an affected scope. Deliberate: resolving on load risked desyncing from
the `DerivedCaches` bundle `on_file_opened` installs right after, without
budget this session to verify that's safe.
3. **Stage 11's conflict resolver is a one-at-a-time pill, not the full
SketchXpert-style panel.** Clicking "⚠ n conflicting" removes one flagged
constraint and re-solves; there's no named-candidate list or cyclable live
preview before committing (design doc §6.4 Phase C). This is the single
largest UX gap remaining in the constraint system relative to the
original plan.
## `ocs_gcs` solver — not ported
- `SlopeAtBSplineKnot` (tangent continuity across a spline knot) — needs
knot-multiplicity reconstruction for a narrower use case than
`PointOnBSpline`, which is ported. Deferred, not silently dropped.
- `InternalAlignmentPoint2Ellipse` / `InternalAlignmentPoint2Hyperbola` —
FreeCAD-Sketcher-internal bookkeeping for *creating* conic geometry
interactively, not for constraining existing geometry. Lower priority.
- `EllipticalArcRangeToEndPoints` — dead code upstream (a `ConstraintType`
enum value with no implementing class anywhere in current planegcs
source), not a gap in this port.
- **Golden-fixture testing against the locally-installed FreeCAD.app**
(testing-strategy "Oracle A") was never wired up. Everything in `ocs_gcs`
is verified only against Oracle B (closed-form + finite-difference). Still
a real asset sitting unused if higher-confidence validation is ever
wanted.
- **Performance at scale is untested.** The "only touched scopes rebuild"
locality argument (design doc §4.2) is architecturally sound but has no
profiling behind it on a large, heavily-constrained drawing.
## Constraint types not exposed in the app's UI (even though some primitives exist)
`ConstraintKind` (`src/scene/sketch_constraints.rs`) currently has:
Coincident, Horizontal, Vertical, Parallel, Perpendicular, Equal, Distance,
Angle, Radius, Tangent. AutoCAD's own Geometric Constraints panel additionally
has **Fix**, **Concentric**, **Collinear**, **Symmetric**, **Smooth** — none
of these have a `ConstraintKind` variant here. Some are buildable from
already-ported `ocs_gcs` primitives (e.g. Concentric ≈ `C2CDistance` at zero
distance) without new solver math; `Fix` would need a different mechanism
entirely (pinning a point's params non-free rather than adding a constraint
row) and doesn't fit the current `ConstraintKind` model at all.
## Newly discovered this session (not previously tracked anywhere)
- **`CCONSTRAINT`, `TCONSTRAINT`, and their four siblings
(`HCONSTRAINT`/`VCONSTRAINT`/`PCONSTRAINT`/`QCONSTRAINT`/`ECONSTRAINT`) have
no `inventory::submit!(CommandRegistration)` registration.** Found while
generating `docs/command_reference.md`. All seven work correctly when
invoked (ribbon button or exact typed name) but don't appear in the
command-line's autocomplete or in the MCP server's `commands` listing,
unlike almost every other command in the app. Pre-existing across the
whole constraint-command family, not something this session's Tangent/
Coincident additions broke uniquely — just never noticed before. Cheap fix
(one `inventory::submit!` block) whenever someone wants to spend five
minutes on it.
- **No live link from a sketch to 3D solid features.**
`SolidHistorySweep`/`Revolve`/etc. (`src/scene/model/solid_history.rs`)
hold `sweep_entity: Option<EmbeddedEntity>` — a geometry copy snapshotted
at creation time, not a `Handle` back into the sketch. Dragging or
re-solving a sketch that was previously extruded/revolved/swept does not
update the resulting solid. This is the real "feature-history tree" gap
discussed when scoping FreeCAD/AutoCAD parity — a substantially bigger
project than the named-parameters work (see `docs/
named_parameters_design.md` §3), not something to fold into it by
accident.
- **No named-parameter/expression sheet** (AutoCAD's Parameters Manager /
FreeCAD's Spreadsheet equivalent). This is the next feature queued up —
see `docs/named_parameters_design.md` for the handoff context.
- **No end-user manual exists anywhere in the repo.** Checked `docs/`
thoroughly — everything there is developer-facing (plugin architecture,
this parametric design doc, tessellation notes, native-vs-web diffing,
release notes, automation scripts) plus localized top-level README
translations. No in-app Help link points anywhere either. Not started;
would be a real, separate writing effort if wanted.
- **No theme install/import/export mechanism.** Theming is entirely in-app:
pick one of iced's built-in themes, or hand-tune one "Custom" slot via six
hex-color inputs (`src/ui/window/options.rs`, `UiThemePalette` in
`src/app/config.rs`). No theme file format, no way to author and share a
theme, only one custom palette remembered at a time.
- **No touch/multi-touch input support**, relevant to the wasm build running
on a tablet (e.g. iPad). Grepped the whole codebase for touch/gesture
handling — none exists; all input assumes mouse+keyboard, relying on the
browser's synthetic mouse-event emulation for taps. No pinch-zoom, no
two-finger pan, and OSNAP/grip-drag precision (already tight even with a
mouse — see the Coincident epsilon note below) would be materially worse
with a fingertip.
## Testing/tooling notes for future live-verification sessions
- **Coincident's success path could not be reliably live-verified via
synthetic mouse clicks** — its resolver epsilon (`COINCIDENT_EPSILON_SQ =
1.0e-12`, ~1e-6 world units, reused from stage 8's `infer_coincident_refs`)
is tighter than this session's `left_click`/OSNAP automation could
reliably hit, even with Endpoint snap enabled. Concluded to be an
environment limitation, not a code defect — automated tests cover the
resolver logic directly — but worth knowing before assuming a future
automated click-through will succeed where this session's didn't.
- **`open_application`/`request_access` always resolve to the
`/Applications`-installed app bundle**, regardless of which binary was
most recently built. To live-test a code change, build a release binary
and swap it into a **scratch copy** of the installed bundle (e.g.
`/tmp/ocs_livetest/OpenCADStudio.app`), keeping the original small
launcher (`CFBundleExecutable = "OpenCADStudio"`) intact, then
`codesign --force --deep -s -` to re-sign ad-hoc before `open`-ing it.
This reuses the real bundle identity (`io.github.HakanSeven12.OpenCadStudio`)
so access requests correctly target it. A standalone bundle with a fresh
bundle id gets silently `user_denied` — not a viable shortcut. The debug
binary run bare via `nohup` never presented a controllable window either
— use a release build.
- **`target/debug` can balloon fast from repeated live-testing rebuilds** —
hit 35GB and caused a real "No space left on device" build failure this
session. `cargo clean` reclaimed 64GB. Worth a proactive `cargo clean` (or
checking `df`) before a long live-testing stretch, not just after it
breaks.
- Synthetic input reliability, generally: Escape frequently failed to cancel
an active command (use Return instead); LINE clicks sometimes produced
stray zero-length duplicate entities; selection clicks often missed thin
line bodies or landed on a midpoint-edit-box zone (use a crossing-window
drag-select instead of precise body clicks).
## Legal / process
- **LGPL-2.1-or-later review for `ocs_gcs` is still open** (original plan
§6). Non-blocking for continued development, but must happen before any
public release containing it — separate-relinkable-module posture vs.
broader relicensing, notice/attribution obligations under GPLv3
distribution.
File diff suppressed because it is too large. Load diff
-414
View File
@@ -1,414 +0,0 @@
# DWG-Native Constraint Compatibility — Design
## 1. What this is, and why
Hakan (upstream maintainer) reviewed the sketch-constraint system
(`docs/parametric_system_design.md`) and asked for it to persist using the
constraint object model already exposed in `acadrust`/`cadcodec` (the pinned
CAD kernel), so a file saved by OpenCADStudio carries constraints AutoCAD or
BricsCAD would recognize as their own native objects — not just an opaque
custom blob only this app understands.
This is purely a **persistence-format** question, not a solver question.
`ocs_gcs` (the FreeCAD `planegcs` port: `crates/ocs_gcs/src/solvers/{bfgs,dogleg,lm}.rs`)
is the numerical engine that computes geometry from a constraint system —
that stays exactly as it is regardless of what this document decides. What
changes is only how `SketchConstraintSet` gets written to and read from a
`CadDocument`, in `src/scene/sketch_persist.rs`, currently a custom
`bincode`-in-XRecord blob keyed `OCS_SKETCH_CONSTRAINTS`.
## 2. What `acadrust` already provides (confirmed from source)
All paths below are relative to the pinned checkout
(`~/.cargo/git/checkouts/cadcodec-*/*/src/`, rev pinned in `Cargo.toml`).
- `objects/associative.rs` defines AutoCAD's real 2D geometric-constraint
object graph as plain Rust types, reachable generically through
`ObjectType::Associative(AssociativeObject { data: AssociativeData::ConstraintGroup(Assoc2dConstraintGroup), .. })`
— the same `document.objects: HashMap<Handle, ObjectType>` map
`sketch_persist.rs` already inserts `XRecord`/`Dictionary` entries into.
- Both DWG (`io/dwg/dwg_stream_{readers,writers}/object_{reader,writer}/associative.rs`,
~1240/~870 lines) and DXF (`io/dxf/{reader,writer}/section_{reader,writer}/associative.rs`)
have **complete read and write** implementations for every type below —
confirmed by reading the actual field-by-field (de)serialization code, not
inferred from the type definitions alone.
- `classes/mod.rs` registers the DXF class names every one of these needs
when written (`(DXF_NAME, "AcDbClassName", ...)` tuples):
`AcDbAssocDependency`, `AcDbAssocGeomDependency`, `AcDbAssocAction`,
`AcDbAssocNetwork`, `AcDbAssoc2dConstraintGroup`, `AcDbAssocVariable`,
`AcDbAssocPersSubentManager`.
### The graph shape
Not a flat list — a small object graph, matching AutoCAD's real associative
framework:
```
AssocNetwork (per block/space that owns constrained geometry)
└─ owned_actions: Vec<Handle> ─→ AssocAction
├─ action_body: Handle ─→ Assoc2dConstraintGroup
│ ├─ work_plane: [Vector3; 3]
│ └─ nodes: Vec<AssocConstraintNode>
│ (both the *constraint* nodes
│ and the *geometry* nodes live
│ in this one Vec — see §3/§4)
└─ dependencies: Vec<AssocActionDependency>
─→ AssocGeomDependency
├─ dependency: AssocDependency
│ └─ dependent_on: Handle
│ (the real Line/Circle/... entity)
└─ persistent_subent: AssocPersistentSubentId
```
`AssocConstraintNode { node_id: i32, status: u8, connections: Vec<i32>,
class_name: String, registry_flag: bool, data: AssocConstraintNodeData }` —
`node_id`/`connections` link nodes to each other *within* the group (a
constraint node's `owner_id` fields point at a geometry node's `node_id`);
`class_name` picks which `AssocConstraintNodeData` variant applies on
read (`read_constraint_node_data(reader, class_name)`,
`dwg_stream_readers/object_reader/associative.rs:663`).
### 2b. Primary sources beyond `cadcodec`'s own code
`cadcodec` ships no constraint-bearing test fixture and its own source only
shows *what bytes get read*, not *what they mean* semantically — §7 (now
mostly resolved) needed Autodesk's own interface documentation to answer.
Two real, first-party sources turned out to be publicly available with no
sample DWG file needed at all:
- **The ObjectARX 2012 SDK's own C++ headers**, mirrored on GitHub at
[Stalso/CadPlugin](https://github.com/Stalso/CadPlugin/tree/master/ObjectARX%202012/inc)
— Autodesk's own copyright header on each file explicitly grants "permission
to use, copy, modify, and distribute this software... for any purpose and
without fee", and this is the SDK Autodesk itself publishes specifically so
third parties can build interoperable tools — not leaked or decompiled
source. `AcDbAssoc2dConstraintGroup.h`, `AcDbAssocNetwork.h`,
`AcDbAssocManager.h`, `AcDbAssocVariable.h`, `AcConstrainedGeometry.h` were
all read directly; their class-level doc comments describe the exact
same object graph `cadcodec` implements, in Autodesk's own words.
- **Autodesk's live public ObjectARX reference**
(`help.autodesk.com/view/OARXMAC/2025/...`) — confirmed the
`AcDb::ImplicitPointType` enum's exact values and per-member descriptions
(used for §4's `point_type` mapping) and `AcDbAssocNetwork`'s
`getInstanceFromObject`/`getInstanceFromDatabase` dictionary-key default
(used for §7 question 1). No login or SDK download needed — the reference
guide is indexed and publicly browsable.
## 3. Constraint-kind mapping
Our `ConstraintKind` (`src/scene/sketch_constraints.rs`) against
`AssocConstraintNodeData` (`objects/associative.rs:885`). The "refs shape"
column is copied from `sketch_solve::build_constraint`'s actual `match`
(`src/scene/sketch_solve.rs:151-208`) — the ground truth for what each kind
expects, not the doc comment.
| `ConstraintKind` | refs shape (from `build_constraint`) | `AssocConstraintNodeData` | Notes |
|---|---|---|---|
| `Coincident` | `[point, point]` | `Geometrical` + `class_name = "ACPOINTCOINCIDENCECONSTRAINT"` | |
| `Horizontal` | `[whole_line]` | `Geometrical` + `"ACHORIZONTALCONSTRAINT"` | |
| `Vertical` | `[whole_line]` | `Geometrical` + `"ACVERTICALCONSTRAINT"` | |
| `Parallel` | `[whole_line, whole_line]` | `Parallel { datum_line_index: None, .. }` | dedicated variant, not `Geometrical` |
| `Perpendicular` | `[whole_line, whole_line]` | `Geometrical` + `"ACPERPENDICULARCONSTRAINT"` | |
| `Equal` (line case) | `[whole_line, whole_line]` | `Geometrical` + `"ACEQUALLENGTHCONSTRAINT"` | |
| `Equal` (circle/arc case) | `[whole_circle, whole_circle]` | `Geometrical` + `"ACEQUALRADIUSCONSTRAINT"` | **our `Equal` doesn't distinguish these — see §5** |
| `Distance` | `[point, point]` | `Distance { direction_type, distance: Option<Vector3>, value_dependency, dimension_dependency, .. }` | needs an `AssocVariable`/value dependency for the driving value — see §5 |
| `Angle` | `[whole_line, whole_line]` | `Angle { sector_type, value_dependency, dimension_dependency, .. }` | same value-dependency need |
| `Radius` | `[whole_circle]` | `RadiusDiameter { mode, value_dependency, dimension_dependency, .. }` | `mode` presumably distinguishes radius vs. diameter — we're always radius |
| `Tangent` | `[circle, circle]` or `[circle, line]`/`[line, circle]` — Line-Line and anything involving an `Arc` are explicitly unsupported today (`sketch_solve.rs:239-241`, `Arc` isn't a supported `EntityGeom` variant yet) | `Geometrical` + `"ACTANGENTCONSTRAINT"` | our own `Arc`-tangent gap predates this document, unrelated to DWG compatibility |
AutoCAD's `is_plain_geometrical_constraint` list also includes several kinds
we have no `ConstraintKind` for at all: `ACCENTERPOINTCONSTRAINT`,
`ACCOLINEARCONSTRAINT`, `ACCONCENTRICCONSTRAINT`, `ACEQUALCURVATURECONSTRAINT`,
`ACEQUALDISTANCECONSTRAINT`, `ACEQUALHELPPARAMETERCONSTRAINT`,
`ACFIXEDCONSTRAINT`, `ACMIDPOINTCONSTRAINT`, `ACNORMALCONSTRAINT`,
`ACPOINTCURVECONSTRAINT`, `ACSYMMETRICCONSTRAINT` — genuinely nothing to map
to yet, not a mapping gap (§5).
## 4. Geometry-node mapping
Every entity a constraint references also needs its own node in the same
`nodes: Vec<AssocConstraintNode>`, so constraint nodes have something to
point `owner_id` at, *and* a paired `AssocGeomDependency` (in the owning
`AssocAction`'s `dependencies`) so the graph — not just the constraint data
— actually references the real `Line`/`Circle`/... entity by `Handle`.
| Our reference | `AssocConstraintNodeData` geometry variant |
|---|---|
| `SketchRef::whole(line)` | `BoundedLine { point, direction, is_ray: false, start_point, end_point, .. }` — confirmed a *bounded* line is the right choice: `Line`/`BoundedLine`'s split exists because AutoCAD's own constrained-geometry set includes true infinite construction lines (`ACCONSTRAINEDCONSTRUCTIONLINE`/`ACCONSTRAINEDDATUMLINE`, `read_constraint_node_data`, `associative.rs:711-714`) as a *separate* case from an ordinary bounded segment; nothing we draw is an infinite line |
| `SketchRef::point(line, 0\|1)` | `ImplicitPoint { point_type: kStartImplicit(0)\|kEndImplicit(1), point_index: -1, curve_id: <the Line/BoundedLine node's node_id>, .. }` — **confirmed**, `AcDb::ImplicitPointType` enum, `kStartImplicit`/`kEndImplicit`/`kMidImplicit`/`kCenterImplicit`/`kDefineImplicit` (0-4), Autodesk's own live ObjectARX reference (§2b). `point_index` is `-1` for every case except `kDefineImplicit` (a spline control point, which we don't reference); `curve_id` is presumably the owning geometry node's own `node_id` (not yet cross-checked against a real file — the *values* are confirmed, this one wiring detail isn't) |
| `SketchRef::whole(circle)` | `Circle { center, normal, direction, radius, start_parameter: 0.0, end_parameter: TAU, .. }` |
| `SketchRef::whole(arc)` | `Arc { .. same fields .. , start_point, end_point }` |
| `SketchRef::center(circle\|arc)` | `ImplicitPoint { point_type: kCenterImplicit(3), point_index: -1, curve_id: <the Circle/Arc node's node_id>, .. }` — confirmed by the same enum (`kCenterImplicit`: "Center point of a circle, arc, ellipse or bounded ellipse") — **not** the separate `Point`/`ACCONSTRAINEDPOINT` variant, which the ObjectARX docs' own class hierarchy (§2b) show is for a *free-standing* constrained point, not one implicitly defined by another curve |
| `SketchRef::point(_, -2)` (reserved, unused today) | would be `kMidImplicit` if ever used — "Mid point of a bounded line ... or arc" is exactly what marker `-2`'s doc comment already reserved this for |
`SketchRef` and this graph already agree on one thing directly: our own doc
comment (`src/scene/sketch_constraints.rs:20-22`) says the `marker`
convention was deliberately borrowed from
`AssocDimensionReference::main_gs_marker` (`src/scene/dimension_assoc.rs`,
already shipping, already reading/writing real `Assoc*` objects for
associative dimensions). That's a different, lighter mechanism than the
constraint graph (an `AcDbOsnapPointRef` chain attached directly to a
`Dimension` entity, not the `AssocNetwork`/`AssocAction` framework — see
`AssocDimensionAssociation`, `objects/associative.rs:1200`), so it's a
precedent for *how this codebase already talks to `Assoc*` types*, not a
template for the constraint graph's own wiring.
## 5. Known gaps (not addressed by this document)
- **`Equal` is one `ConstraintKind`, AutoCAD has (at least) two**:
`ACEQUALLENGTHCONSTRAINT` (lines) vs. `ACEQUALRADIUSCONSTRAINT`
(circles/arcs), plus `ACEQUALCURVATURECONSTRAINT`/
`ACEQUALDISTANCECONSTRAINT` we don't model at all. `build_constraint`
already branches on `refs`' resolved shape at solve time
(`sketch_solve.rs:181-188`), so the same branch can pick the write-side
`class_name` — no data-model change needed for the length/radius split;
the other two AutoCAD sub-kinds are a real, separate gap.
- **`DrivingValue::Named` — confirmed, has a real native equivalent, and the
full wiring is now traced end to end.**
`AcDbAssocVariable` (`objects/associative.rs:1024`: `name`, `expression`,
`evaluator`, `description`, cached `value`) is fully read *and* written on
both DWG and DXF (confirmed in
`dwg_stream_readers/object_reader/associative.rs:1031-1063` and the DXF
reader/writer equivalents) — it's the same associative-action graph a
constraint group lives in (`AssocVariable` wraps an `AssocAction`, exactly
like `Assoc2dConstraintGroup` does), not a separate table.
The two handles on `Distance`/`Angle`/`RadiusDiameter`
(`read_explicit_constraint`, `associative.rs:652-661` — reads them in this
order: `value_dependency` first, `dimension_dependency` second) resolve
to two different, unrelated things:
- **`value_dependency`** → an `AssocValueDependency` object
(`{ dependency: AssocDependency, name, value: AssocEvalVariant }`,
`objects/associative.rs:554`). Its own `value: AssocEvalVariant`
(`{ code: i16 /* DXF resbuf type */, value: AssocEvalValue::Real(f64) }`,
confirmed `associative.rs:579-596`) is the **cached, already-resolved
number** — always present, read fast without evaluating anything. The
embedded `AssocDependency.dependent_on: Handle`
(`read_dependency`, `associative.rs:83-109`) is what actually links to a
named parameter: `Handle::NULL` when the driving value is a bare
literal, or an `AssocVariable`'s handle when it's driven by one. This
gives a direct, confirmed mapping:
- `DrivingValue::Literal(v)` → `AssocValueDependency` with
`value = Real(v)`, `dependency.dependent_on = Handle::NULL`.
- `DrivingValue::Named(name)` → the same, but `dependency.dependent_on`
points at an `AssocVariable{ name, expression: <our formula>, .. }`
object (create once per distinct name, reused by every constraint
referencing it — `value` still carries the current resolved number,
kept in sync with `ParameterTable`'s last evaluation, same duty our
own `dof`/`conflicts` caches already have).
- **`dimension_dependency`** → a *separate* dependency chain
(`AssocAnnotationDependency`/`AssocDimDependencyBody`,
`associative.rs:744-751` and `:1077-1082`) linking to the actual
`Dimension` entity AutoCAD draws next to a constraint to show its
current value on screen — nothing to do with named parameters at all.
Not required for our purposes unless we also want the constraint to
carry a visible on-canvas dimension the way AutoCAD's does (open
question, not yet decided — §7).
Remaining gap before mapping directly: AutoCAD's `expression` is
evaluated by a named, extensible `evaluator` (not fixed) — real-file
survey needed to know what evaluator name(s) actually appear in practice
(§7 open question 4), ours has one hardcoded evaluator.
- **Missing `ConstraintKind`s**: Concentric, Colinear, Symmetric, Fixed,
Midpoint, Normal, CenterPoint, PointOnCurve — real AutoCAD constraint
types with no `ConstraintKind` variant to map from at all. Out of scope
until/unless the sketch-constraint system itself grows them.
- **`Tangent` involving an `Arc`** has no native mapping to design, since
`build_constraint` doesn't support it at all yet (`sketch_solve.rs:239-241`
— `Arc` isn't a supported `EntityGeom` variant in that function). A
pre-existing gap in the constraint system itself, unrelated to and not
blocking this document.
## 6. Persistence architecture: additive, not a replacement
**Proposal: write both.** Keep `OCS_SKETCH_CONSTRAINTS` as the authoritative
round-trip format OpenCADStudio itself reads back
(`sketch_persist::load_sketch_constraints_from_document`), and *additionally*
materialize the `AssocNetwork`/.../`Assoc2dConstraintGroup` graph alongside
it on save, for other applications to read.
Reasoning:
- Our own model carries information the native graph can't represent
losslessly today — `DrivingValue::Named` chief among them (§5), also
`enabled` (a suppressed-but-kept constraint) which may or may not have a
clean native equivalent (`AssocConstraintNodeData`'s `is_active` field on
several variants looks like the right fit, but unconfirmed, §7).
Round-tripping through the native format only and reconstructing our
model from it risks silent data loss on every save/reopen cycle.
- The native graph's own geometry-node fields (`center`, `radius`, `point`,
...) are *snapshots* of the current solved geometry, not derived at load
time — so they need to be kept in sync with whatever `ocs_gcs` last
solved, same as we already do for our own tessellated wire geometry. Two
representations of "the same" constraint, kept consistent by one save
path, is a smaller invariant to maintain than trying to make the native
graph the *only* source of truth for a system it can't fully express.
- This is reversible: if the native graph turns out to work well enough
(once tested against real AutoCAD/BricsCAD opens, §7) to *become* the
primary format later, dropping the redundant `OCS_SKETCH_CONSTRAINTS`
write is a small follow-up, not a rewrite.
## 7. Open questions
Three of the original five are now resolved from Autodesk's own primary
sources (§2b), not just inferred from `cadcodec`'s struct shapes — kept
here with their answers so the reasoning stays visible, not deleted.
`cadcodec` itself still ships no constraint-bearing DWG/DXF fixture and no
constraint-specific test (checked: no hits for `Assoc2dConstraintGroup`/
`AssocConstraintNode` under any `tests/` directory or fixture path in the
dependency), so even the resolved ones haven't been checked against a real
written-and-reopened file yet — that step still matters (§8 stage 1) for
confirming implementation correctness, just not for the *design* questions
below anymore.
1. **Resolved.** Where the top-level `AssocNetwork` attaches:
`AcDbAssocNetwork::getInstanceFromDatabase`/`getInstanceFromObject`'s own
doc comments (§2b) state the default sub-dictionary key directly —
`"ACAD_ASSOCNETWORK"` — used both for the one top-level network (owned by
the database's Named Object Dictionary) and for each per-block-record
network (owned by that `AcDbBlockTableRecord`'s own extension
dictionary — confirming the parallel to `OCS_SKETCH_CONSTRAINTS`'s own
attachment was right). The two are linked: creating a per-block network
with `addToTopLevelNetwork = true` (the default) also registers it as an
action inside the top-level network, so evaluating the top-level network
cascades through every block's sub-network.
2. **Resolved.** Sub-entity addressing for `ImplicitPoint`: confirmed the
real addressing lives entirely in the constraint-node's own
`point_type`/`point_index`/`curve_id` fields, not in
`AssocPersistentSubentId` (that type turned out to be an abstract C++
base class for a *different*, more general subentity-tracking need
elsewhere in the associative framework — irrelevant to 2D constraint
geometry). `AcDb::ImplicitPointType`'s exact enum (§4) settles the
mapping.
3. **Still open.** Whether a partially-populated graph (e.g. omitting
`AssocPersSubentManager`, or leaving some `AssocAction` bookkeeping
fields at their `Default`) still opens cleanly in real AutoCAD, or
whether it requires every field correctly populated to avoid a repair
prompt or silent constraint loss. Genuinely needs a real write-then-open
test, not documentation — this is a robustness/tolerance question, not a
structural one.
4. **Narrowed to almost nothing.** `AssocVariable`'s `evaluator` field:
`AcDbAssocVariable.h`'s own doc comment (§2b) says plainly — "If empty,
the default evaluator for current acad version will be used." Leaving it
as an empty string is officially-supported behavior per the SDK itself,
not a guess. No further investigation needed unless a real file later
shows this doesn't round-trip as expected.
5. **Still open.** DWG version gating — `AssocConstraintNode`'s own reader
already branches on `version.r2013_plus(dxf_version)` for one field
(`dwg_stream_readers/object_reader/associative.rs:604`); unclear whether
other fields need per-version handling this document hasn't looked for.
Likely answerable the same way as questions 1/2/4 were (Autodesk's own
docs usually note version-specific members) — just not done yet.
6. **Still open, low priority.** Whether to bother wiring
`dimension_dependency` at all (§5) — it drives the small on-canvas
dimension label AutoCAD shows next to a constrained value, cosmetic
rather than structural. Leaving it `Handle::NULL` is presumably safe (the
constraint itself is still fully defined without it) but unconfirmed;
deferring this to a later stage either way (§8).
## 8. Suggested staged approach
1. **Proof of concept, narrowest possible slice**: `Horizontal`/`Vertical`
on a single `Line`, no dimensional constraints (sidesteps the
`AssocVariable`/value-dependency wiring entirely, though that's now fully
designed too — §5). Write the graph — `AssocNetwork` at
`"ACAD_ASSOCNETWORK"` on the scope owner's extension dictionary (§7.1),
one `Assoc2dConstraintGroup` action, one `BoundedLine` geometry node, one
`Geometrical`/`"ACHORIZONTALCONSTRAINT"` (or vertical) constraint node —
and open the file in whatever real-DWG-reading tool is available, to
confirm AutoCAD/BricsCAD actually shows a constraint icon on the line and
doesn't flag the file as corrupt. This is what answers open question 3
(partial-graph tolerance) — the one remaining question documentation
alone can't settle.
2. **Geometric constraints, still no driving value**: add `Coincident`
(needs `ImplicitPoint`/`kStartImplicit`/`kEndImplicit`, §4, now
confirmed), `Parallel`, `Perpendicular`, `Equal` (both sub-kinds) —
verify against the same real-file test from stage 1.
3. **Dimensional constraints**: `Distance`, `Angle`, `Radius`, using the now
fully-traced `value_dependency → AssocValueDependency → AssocVariable`
chain (§5) — including deciding whether `DrivingValue::Named` gets the
real native mapping designed there, or stays a resolved-literal degrade
for a first pass.
4. **Everything else**: `Tangent`'s non-circle-circle cases, the missing
`ConstraintKind`s (§5) if/when the sketch-constraint system grows them,
`dimension_dependency` (§7.6) if a visible on-canvas dimension label
turns out to matter.
Each stage's file should be hand-inspected (or opened in real software)
before moving to the next — the graph's correctness genuinely can't be
verified by this codebase's own round-trip tests alone, since a bug that's
symmetric (write it wrong, read the same wrongness back) would pass here and
still fail to open correctly elsewhere.
## 9. Progress
**Implemented** (`src/scene/dwg_native_constraints.rs`, wired into both save
paths in `src/app/update/file.rs` alongside the existing
`materialize_sketch_constraints_for_save`/`materialize_named_parameters_for_save`
calls). All 4 stages from §8 landed together rather than incrementally, since
`ConstraintKind` only has the 10 variants already covered — there was no
narrower slice left once Horizontal/Vertical's plumbing (network/dictionary
creation, the two-pass builder, class-table registration) was in place.
Covers: every `ConstraintKind` (`Coincident`, `Horizontal`, `Vertical`,
`Parallel`, `Perpendicular`, `Equal`'s line/circle split, `Distance`,
`Angle`, `Radius`, `Tangent`), `AssocGeomDependency` for whole-geometry nodes,
`AssocValueDependency`/`AssocVariable` for dimensional constraints including
`DrivingValue::Named`, and a resave-clears-and-rebuilds cleanup pass
(`remove_owned_recursive`) so materializing twice doesn't accumulate orphans.
Verified via real `save_to_bytes`/`load_bytes` round trips through
`acadrust`'s own reader/writer (5 tests in that module) — this confirms
self-consistency with `acadrust`'s implementation of the documented format,
**not** real-world AutoCAD/BricsCAD compatibility (§7.3/§7.5 below remain
unverified; no such software is available in this environment).
Two implementation-time findings not anticipated by the design above:
- **The top-level NOD-anchored network was dropped from scope.**
`named_parameters_persist.rs`'s own doc comment already documents a real
`acadrust` bug: a `Dictionary`-type object (like the database's Named
Object Dictionary) used as an XRecord/dictionary owner loses its extension
data through a DXF save specifically (`CadDocument::ensure_xrecord` only
patches the owner's `xdictionary_handle` when `get_entity_mut(owner)`
succeeds — a Dictionary isn't an entity, so it silently falls back to a
DWG-only side map). Rather than hit that bug, this module only creates a
network per `SketchScope::owner_handle()` (a real `BlockRecord` entity) —
the same already-proven-safe attachment point `sketch_persist.rs` uses. No
top-level network is created at all.
- **DXF cannot carry the dependency chain — DWG is the only fully-faithful
format.** Confirmed by reading `acadrust`'s DXF writer
(`io/dxf/writer/section_writer.rs`'s `is_unrestorable_assoc_object`): it
deliberately skips writing `AssocDependency`/`AssocValueDependency`/
`AssocGeomDependency`/`AssocVariable` objects to DXF at all
("Unrestorable associative-framework objects are not written") — DWG has
no equivalent filter. A DXF save keeps the `Assoc2dConstraintGroup`/
`AssocNetwork` shell and every node's own embedded geometry, but loses live
geometry/value linkage and every `AssocVariable`. This module still
materializes the same graph for both formats (the shell that survives DXF
is strictly more than nothing, and dangling handles are an ordinary,
tolerated pattern in both formats) rather than special-casing DXF to write
less. Demonstrated directly by
`a_dwg_save_keeps_the_full_dependency_chain_including_the_named_variable`
vs. `a_dxf_save_keeps_only_the_constraint_group_shell` in that module's
test suite.
Also confirmed while implementing (not previously documented): the DWG
writer's `Assoc2dConstraintGroup` arm only emits node data at all when
`nodes.first()` is `Some` — an empty `nodes` `Vec` skips node serialization
entirely — so `nodes[0]` must always be a synthetic root pseudo-node (empty
`class_name`, connected to every real node) even for a single-constraint
graph; and a multi-geometry constraint (e.g. `Parallel`) references its
second geometry via the generic `connections: Vec<i32>` field on
`AssocConstraintNode`, not a second dedicated field on
`AssocConstraintNodeData::Parallel` — confirmed from `AcConstraintGroupNode`'s
own doc comment ("the connection between two nodes is not directed").
Of the original five open questions, three were resolved from Autodesk's own
primary documentation (§7.1, 7.2, 7.4) before implementation started — the
network's dictionary key, `ImplicitPoint`'s addressing enum, and the
`AssocVariable` evaluator default were confirmed, not guessed. Two remain
open (§7.3 partial-graph tolerance, §7.5 version gating) and still genuinely
need a real AutoCAD/BricsCAD open, not just more reading — self-consistency
through `acadrust`'s own round trip can't settle either one. Next action is
opening a saved DWG in real software (or at minimum a third-party DWG
inspector) to make progress on those two, and posting the GitHub Discussion
Hakan asked for once that's done.
-477
View File
@@ -1,477 +0,0 @@
# Named Parameters — Design & Handoff
**Status: not started.** This document is the starting context for a new
coding session (mirrors how `docs/parametric_system_design.md` was used for
the sketch-constraint-solver project — read that document's own "Progress"
section for the sibling feature this one builds alongside). The plan file at
`/Users/felix/.claude/plans/snoopy-finding-matsumoto.md` indexes both.
## 1. What this is, and why
OpenCADStudio now has real sketch-level parametric constraints (Coincident,
Horizontal/Vertical, Parallel/Perpendicular, Equal, Tangent, Distance, Angle,
Radius — see `docs/parametric_system_design.md`, fully built and tested).
What it does **not** have is a way to drive several of those dimensional
values from one shared, named, formula-capable source — FreeCAD calls this
its Spreadsheet workbench; AutoCAD calls it the **Parameters Manager**
(`PARAMETERS` command, since AutoCAD 2010) — a table of named variables
(`hole_dia`, `plate_len`, …) that a dimensional constraint's value can
reference instead of a bare literal, so editing the named value ripples to
every constraint that references it. This is explicitly the AutoCAD-parity
gap identified in the "AutoCAD equivalent" discussion this session (see
§7 for that framing) — we are not inventing a new concept, we're filling in
a specific, well-precedented one.
This is a genuinely separate feature from the constraint solver itself: it
needs a new document-level object, an expression parser, and a change to how
driving values are stored — not new `ocs_gcs` solver math.
## 2. Prior architecture decisions (from design discussion, not yet coded)
- **New document-level table object**, one per document (not per-scope/sketch
— parameters are meant to be shared across a whole drawing), holding
`name -> literal-or-formula`. Likely home: mirror
`src/scene/sketch_persist.rs`'s pattern exactly — an `XRecord`/`Dictionary`
blob on a well-known owner (that file's `XRECORD_KEY` /
`materialize_*_for_save` / `load_*_from_document` shape is the direct
precedent to copy, including the version-prefixed bincode envelope and the
hook points into `on_file_opened` / `prepare_native_save` / the wasm save
path / the automation `"new"`/`"open"` ops).
- **Expression parser/evaluator** — a small formula language (`2 * hole_dia +
1.5`), with dependency-cycle detection (A referencing B referencing A must
be rejected, not silently infinite-loop or panic). No existing dependency
in the workspace does this — check crates.io options (e.g. `evalexpr`,
`meval`) against the license stack (GPLv3 app; anything permissive/LGPL is
fine) before hand-rolling one.
- **Driving-value call sites become "literal or named reference"**. The
concrete, known change: `SketchConstraint::driving_param` is currently
`Option<f64>` (`src/scene/sketch_constraints.rs:97`), consumed directly in
`sketch_solve.rs` (`build_constraint`, lines ~184/190/195, one per
Distance/Angle/Radius) via `sys.add_param(c.driving_param?, ...)`. This
would become something like `DrivingValue::Literal(f64) |
DrivingValue::Named(String)`, resolved against the parameter table at
solve time (same rebuild-from-scratch-per-trigger model the constraint
solver already uses — no incremental re-evaluation needed, consistent with
`refresh_sketch_constraints`'s existing "full rebuild per trigger, not
incremental" design choice).
- **Solid-history properties are a second, separate call-site family** —
`src/scene/model/solid_history.rs`'s `PROP_LENGTH`/`PROP_HEIGHT`/etc. are
plain `f64` fields on structs like `SolidHistoryBox` (e.g. `value.length`,
set via the generic property-editing dispatch at `solid_history.rs:2239`).
Whether named parameters should reach these too (so a box's height can
reference the same `plate_thickness` a sketch dimension uses) is a scope
decision for this session to make explicitly, not assume — see open
questions below. If yes, it's the same "literal or reference" change
repeated across a different, larger set of call sites (many more `PROP_*`
constants than there are `ConstraintKind`s).
## 3. Explicitly out of scope for this feature (confirmed in prior discussion)
- **Live sketch-to-3D linking** (extrude/sweep/loft tracking a sketch's
constraint solve) is a separate, larger project. Confirmed this session:
`SolidHistorySweep`/`Revolve`/etc. hold `sweep_entity: Option<EmbeddedEntity>`
— a **snapshotted copy** of the profile geometry, not a live `Handle` back
into the sketch (`src/scene/model/solid_history.rs`, grep `sweep_entity`).
Named parameters can reference solid-history properties (per §2's open
question) without needing this link — that's a narrower, additive step.
Do not conflate the two.
## 4. Suggested staged approach
Mirroring what worked well for the constraint-solver project (`docs/
parametric_system_design.md`'s own staged plan): small, independently
testable stages, each with its own automated tests, progress notes appended
to this document (not a separate changelog) after each stage, live UI
verification where the stage has new UI surface.
1. Data model: the parameter table type + expression parser, unit-tested in
isolation (parse, evaluate, cycle detection) with no document/UI
integration yet.
2. Persistence spike: XRecord round-trip for the new table, following
`sketch_persist.rs`'s exact precedent — prove save/load byte-fidelity
before wiring anything else to it (this is the same order stage 2 of the
constraint-solver plan used, and it caught real assumptions early there).
3. Wire `SketchConstraint::driving_param` to resolve through the table
(Distance/Angle/Radius constraints only, the three existing call sites).
4. UI: a parameters panel (add/rename/delete a named value, edit its
formula, see its resolved number and any error) — likely the
`src/ui/style/dimstyle.rs`-pattern panel already used elsewhere in this
codebase, and a way to pick "use a named parameter" instead of typing a
literal wherever a Distance/Angle/Radius constraint's value is entered
today (`src/modules/draw/constrain/value.rs`'s `DistanceConstraintCommand`
/ `AngleConstraintCommand`).
5. (If scoped in per §2/§3) extend to `solid_history.rs`'s `PROP_*` values.
## 5. Open questions to resolve before/while coding (not resolved by this doc)
1. **Solid-history scope** — does v1 include primitive solid dimensions
(box length, cylinder radius, …), or sketch dimensional constraints only?
Affects how much of §4's stage 5 is in scope.
2. **Expression language surface** — arithmetic only, or also functions
(`min`, `sqrt`, trig)? AutoCAD's Parameters Manager supports a fairly rich
set; matching all of it is not required for v1 but the parser's grammar
should be chosen so functions can be added later without a rewrite.
3. **Undo granularity for a parameter edit** — a named-parameter edit can
ripple through many constraints across many scopes in one edit, similar
to (bigger than) a single constraint's own resolve. Should probably reuse
the same `record_undo_before`/one-history-entry-per-trigger model
`refresh_sketch_constraints` already established, but confirm rather than
assume.
4. **Where does the parameter table live for multi-document workflows?**
Per-document (confirmed in §2) — but does a block/xref carry its own
independent table, or only the top-level document? `sketch_persist.rs`'s
per-`BlockRecord` XRecord scanning pattern suggests per-block-record is
natural to copy, but sketch scopes and a shared parameter table are not
the same kind of thing (scopes are naturally per-sketch; parameters are
naturally document-wide) — don't copy the pattern mechanically without
checking this distinction holds.
5. **Naming collisions / reserved words** — needs a validation rule (valid
identifier characters, no collision with another parameter, sensible
error on a malformed formula) before the UI stage.
## 6. Progress
**Stage 1 done and tested.** `src/scene/named_parameters.rs`, new module
(registered `pub mod named_parameters;` in `src/scene/mod.rs`, alongside
`sketch_constraints`/`sketch_persist`/`sketch_solve`). Pure data + parser,
no document/UI integration yet, per this stage's own scope.
- **`Expr`**: a small AST (`Number`, `Ref`, `Neg`, `Add`/`Sub`/`Mul`/`Div`/
`Pow`, `Call`) plus a hand-rolled tokenizer/recursive-descent parser
(`parse`) — ordinary precedence (`+`/`-` < `*`/`/` < unary `-` < `^`,
right-associative `^`), parens, and scientific-notation numeric literals
(`1.5e-2`). **Deviation from this doc's own §2 suggestion** to check
`evalexpr`/`meval` first: hand-rolled anyway, because cycle detection
needs the set of names an expression references *before* it's evaluated
(`Expr::refs`), and owning the parse tree gives that for free — treating
evaluation as a black box the way a generic crate is normally used would
just mean re-deriving the same dependency information some other way. No
new external dependency added.
- **§5 open question 2 (expression language surface)**: resolved for v1 as
arithmetic + a small, deliberately conservative builtin function set
(`sqrt`, `abs`, `pow`, `min`, `max` — `call_builtin`), chosen as low-risk,
commonly-needed CAD primitives rather than a considered product decision
on the full surface. The grammar's `Call(String, Vec<Expr>)` node means
adding more later needs no parser rewrite, matching this doc's own
call-out. Builtin names are reserved (`is_reserved_name`) so a parameter
can never collide with one — resolves part of open question 5 (naming
collisions) alongside `is_valid_name`'s identifier rule (leading
letter/underscore, then alphanumeric/underscore).
- **Cycle detection happens at `ParameterTable::set` time, not at
resolve/evaluate time** — the design doc's "must be rejected" read as
real-time rejection (matching a spreadsheet's usual UX), not a
resolve-time error. `set` walks the dependency graph from the candidate
formula's own references, through every already-stored formula, looking
for a path back to the name being defined; a reference to a name that
doesn't exist *yet* just terminates that branch rather than counting as a
cycle (order of definition doesn't matter, same as a spreadsheet formula
referencing an empty cell) — it only becomes
`ParamError::UndefinedReference` if `resolve`/`resolve_all` actually needs
that value later and it's still missing. A `resolve`-time cycle guard
exists too (`resolve_inner`'s `stack` check) but is defense-in-depth only
— unreachable through the public API, since `set` already refuses to
create one; the code comment is explicit that this is a safety net (e.g.
against a foreign/hand-edited file deserialized straight into a table
without going through `set`), not a path the design expects to hit.
- **`resolve_all` isolates a failure to just the parameters it actually
affects**, not the whole table — matching this codebase's established
don't-let-one-bad-item-take-down-everything-else pattern from the
constraint system's own dangling-reference handling.
- `ParameterTable::set` is a single upsert (define-or-redefine), not two
separate "must not exist" / "must already exist" entry points — a later UI
stage that wants add-vs-rename semantics can check `contains` itself
first; not a decision this data-layer stage needed to make.
- 17 unit tests: precedence/associativity, scientific notation, malformed
formulas, builtin arity/unknown-function errors, division by zero, name
validation, literal and chained-reference resolution, undefined-reference
reporting, direct and indirect (closes-later) cycle rejection, redefining
a parameter to *break* a would-be cycle, `resolve_all` isolation,
remove-then-resolve leaving a dependent gracefully undefined (not
panicking), upsert semantics, insertion-order stability across
redefinitions, and a `bincode` round-trip (both `Expr` and `Parameter`
derive `Serialize`/`Deserialize` already, ready for stage 2's XRecord
persistence — not exercised through the real save/load path yet, just
in-memory `bincode::serialize`/`deserialize`, the same distinction stage 4
of the sketch-constraint project drew between its own in-memory-agreement
test and its real-save-path tests).
Full regression check: `cargo test --workspace`, 686 passed / 1 failed (the
same pre-existing, unrelated Turkish-glyph-fallback font test noted
throughout `docs/parametric_system_design.md`) / 13 ignored. `cargo clippy
--lib` shows no new warnings in `named_parameters.rs` (pre-existing warnings
elsewhere untouched).
**Still open after stage 1** (per §5): open question 1 (solid-history
scope), 3 (undo granularity), and 4 (per-document vs per-block-record table
scope) — none needed answering yet since nothing here touches the document
model or undo. Open question 2 and part of 5 got a working v1 answer above,
not a final product ruling.
**Stage 2 done and tested.** `src/scene/named_parameters_persist.rs`, new
module (registered `mod named_parameters_persist;` in `src/scene/mod.rs`),
following `sketch_persist.rs`'s exact precedent: the "lazy" model, a
version-prefixed `bincode` blob in one `Chunk`-typed `XRecordEntry`,
materialized right before save and read back right after load. Also went
ahead with the "wire it into open/save" half of stage 2 (not just the
spike) since it's the same four call sites `sketch_persist` already touches
and there was no reason to leave it half-done:
`OpenCADStudio::prepare_native_save` and the wasm save path
(`src/app/update/file.rs`), `OpenCADStudio::on_file_opened`'s native/web
open path (`src/app/update/file.rs:1393`), and the automation `"open"`/
`"new"` ops (`src/app/automation.rs`) — `named_parameters` lives on `Scene`
(`Scene::named_parameters: named_parameters::ParameterTable`, alongside
`sketch_constraints`, for the same co-location-with-`document` reason
stage 3 of the constraint-solver project gave), with `Scene::
named_parameters`/`named_parameters_mut` accessors.
- **A real round-trip gap found and worked around, not patched**: the
obvious owner handle for a document-wide (not per-scope) XRecord is
`document.header.named_objects_dict_handle` — the vendored `cadcodec`
source's own `extension_dictionary_handle` explicitly resolves an
extension dictionary for a `Dictionary`-type owner, and that crate's own
test suite (`tests/issue51.rs`) uses exactly that handle as an XRecord
owner. In practice this round-tripped through DWG but **silently dropped
the XRecord through DXF** — caught by this stage's own real-bytes save/
load tests, not assumed to work from reading the source alone (the same
discipline `sketch_persist`'s stage 4 applied). Root cause, traced into
the vendored source: `CadDocument::ensure_xrecord` only writes the
*owner's own* `xdictionary_handle` field when `get_entity_mut(owner)`
succeeds (i.e. `owner` is a real "entity"); a `Dictionary` owner like the
root NOD isn't one, so `ensure_xrecord` falls back to a runtime-only side
map (`xdic_by_handle`) instead. The DWG writer consults that side map;
grepping `io/dxf/writer/` for it turns up nothing — the DXF writer only
ever reads the owner's own `xdictionary_handle` field, never the side map,
so a Dictionary-owned extension dictionary is simply invisible to a DXF
save. This is a gap in the vendored crate for this specific owner-type
combination. Per this project's own stated preference (§1.4 of the sibling
design doc: "requires zero changes to the external `acadrust`/`cadkernel`
crates") the fix taken was to pick a different owner rather than patch
the dependency: **`document.header.model_space_block_handle`** — a real
`BlockRecord` entity, the exact anchor `sketch_persist`'s own
`SketchScope::ModelSpace` case already proved clean through both formats.
Not a perfect semantic fit (a parameter table has nothing to do with model
space specifically) but it's guaranteed to exist in every `CadDocument`
and is a real entity, which is the property that actually matters. Worth
revisiting if a later need for a true document-root-anchored XRecord comes
up — the fix would be in `cadcodec` itself (teach `ensure_xrecord` to also
set a `Dictionary`/`Layout`/`XRecord`/`PlotSettings`/`VisualStyle`/
`Material`/`ProxyObject` owner's own `xdictionary_handle` field, not just
entities'), not something to route around a second time.
- 5 new tests (`named_parameters_persist.rs`): in-memory materialize+reload
agreement, real `save_to_bytes`/`load_bytes` round trips for both DXF and
DWG (the two that caught the gap above), an empty table not persisting,
and format-version rejection — same shape as `sketch_persist`'s own test
suite.
Full regression check: `cargo test --workspace`, 691 passed / 1 failed (same
pre-existing, unrelated font test) / 13 ignored. `cargo clippy --lib` shows
no new warnings in either `named_parameters.rs` or
`named_parameters_persist.rs`.
**Stage 3 done and tested.** `SketchConstraint::driving_param` is now
`Option<named_parameters::DrivingValue>` (`sketch_constraints.rs`) instead
of `Option<f64>` — exactly this doc's own §2 suggested shape:
```rust
pub enum DrivingValue {
Literal(f64),
Named(String),
}
```
with `DrivingValue::resolve(&self, table: &ParameterTable) -> Result<f64,
ParamError>` (`named_parameters.rs`) doing the lookup — a literal resolves
to itself, a named reference resolves through the table.
- **Solve-side wiring**: `sketch_solve.rs`'s `build_constraint`/`solve_scope`
now take a `&ParameterTable` (threaded from `Scene::named_parameters` at
both call sites, `refresh_sketch_constraints` and
`solve_sketch_constraints_preview`) and resolve `driving_param` at the
three existing call sites (Distance/Angle/Radius) via `c.driving_param.
as_ref()?.resolve(params).ok()?` — a resolve failure (an undefined
reference, a division by zero; a cycle should be unreachable since `set`
already refuses to create one) is treated exactly like every other
unbuildable-constraint case `build_constraint`'s own doc comment already
documents: the constraint is skipped for this solve, not an error.
- **Judgment call, not dictated by this doc**: how a resolve failure should
surface wasn't an open question this doc raised explicitly, so the
existing precedent decided it — matching a dangling-reference / unbuildable
constraint rather than inventing a new "parameter error" state. A richer
surfacing (e.g. a distinct glyph color, a status-bar warning naming the
broken reference) is possible future work once stage 4's UI exists to
make it discoverable, not something this data/solver-layer stage needed
to add speculatively.
- **`CmdResult::AddSketchConstraint` intentionally left as `Option<f64>`**
(`src/command.rs`) rather than also switching it to `DrivingValue` — no UI
can author a `Named` reference yet (that's stage 4's picker), so every
existing call site (`DistanceConstraintCommand`/`AngleConstraintCommand`
in `src/modules/draw/constrain/value.rs`, the Horizontal/Vertical/etc.
dispatch in `src/app/commands/draw.rs`) needed no changes at all; the one
conversion point is `command_driver.rs`'s `CmdResult::AddSketchConstraint`
handler, which now wraps the literal into `DrivingValue::Literal` right
before calling `SketchConstraintSet::add`. Keeps this stage's diff to the
data/solver layer plus one narrow seam, not a ripple through every typed-
value UI command.
- **`glyph_label`** (`sketch_constraints.rs`) now matches on `&DrivingValue`
instead of a bare `f64`: a literal renders exactly as before, a named
reference renders its name (e.g. "↔ hole_dia") rather than a resolved
number — showing the resolved value too would need threading
`ParameterTable` into the glyph-render call site
(`src/ui/overlay.rs`), which is UI-layer work for stage 4, not this one;
this arm exists only so the match stays exhaustive ahead of that UI (no
command can produce a `Named` driving_param yet, so it isn't reachable
today).
- **`sketch_persist::FORMAT_VERSION` bumped 1 → 2**: `SketchConstraint`'s
serialized shape changed (a bare `f64` became an enum with its own
discriminant), so a version-1 blob's bytes no longer line up with the
current struct — exactly the "detected and gracefully skipped" case that
version byte exists for (§5.2's own call-out: no migration path exists,
bumping the version is the correct response, not a substitute for one). A
previously-saved file's sketch constraints will not reload after this
change; acceptable for personal-use, pre-release local work, not something
a real migration was owed here.
- 2 new tests (`tests/sketch_constraints_solve.rs`): a `Distance`
constraint driven by a named reference solves correctly and re-solves
when the parameter's own value is redefined; a `Distance` constraint
referencing an undefined name is skipped without panicking, leaving the
triggering edit untouched (the same contract the existing "unrelated edit
doesn't trigger a resolve" test already established for dangling
refs). Every existing `driving_param`-touching test (in
`sketch_constraints.rs`, `sketch_persist.rs`,
`tests/sketch_constraints_xrecord_roundtrip.rs`,
`tests/sketch_constraints_solve.rs`) updated to construct
`DrivingValue::Literal(...)` instead of a bare float; all still pass.
- **Testing-methodology note, not a code defect**: discovered while
re-verifying this stage that plain `cargo test --workspace` stops after
the first *crate's* test failures and silently skips the remaining
integration-test binaries in the workspace — the pre-existing, unrelated
font-fallback failure was silently truncating every regression check this
whole project has run with a bare `cargo test --workspace`. Re-ran with
`--no-fail-fast` for this stage's check and confirmed every binary (not
just the lib crate) passes except that one known-unrelated test. Worth
using `--no-fail-fast` for any future full-workspace check in this repo.
Full regression check: `cargo test --workspace --no-fail-fast`, every test
binary passing except the same pre-existing, unrelated font test noted
throughout this document and its sibling. `cargo clippy --lib --tests`
shows no new warnings on any line this stage touched (the two warnings that
do appear in `sketch_solve.rs`/`scene/mod.rs` predate this stage and are on
code this stage didn't modify).
**Stage 4 done and tested (not live-verified in the running app — see the
note below).**
- **The parameters panel** (`src/ui/window/named_parameters.rs`, new): an
in-canvas modal opened by a new `PARAMETERS` command (AutoCAD's own name
for the equivalent feature — `src/app/commands/view.rs`, dispatched
alongside `ALIASEDIT`; registered in the autocomplete/MCP command list
from the start, unlike the six pre-existing constraint commands
`docs/backlog.md` flags as missing that registration). Built by mirroring
`alias_editor.rs`'s buffered-rows pattern as closely as possible rather
than inventing new UI conventions: rows are `(name, formula)`, edited in a
working buffer (`OpenCADStudio::named_parameter_editor_rows`,
`ModalKind::NamedParameters`) and only committed to `Scene::
named_parameters` on Apply — a better fit here than for aliases, even:
`ParameterTable::set` validates (and can reject) a formula immediately, so
writing through to the live table on every keystroke would fight a user
mid-type.
- **Live preview column, beyond what the alias editor has**: each row also
shows its resolved value or its error, recomputed from a scratch
`ParameterTable` built from the *whole buffer's current text* on every
render (`preview`, private to the UI module, unit-tested directly) — not
cached, matching the "cheap enough to rebuild from scratch" philosophy
`ParameterTable`/`sketch_solve.rs` already use throughout. This means a
cross-row problem (a forward reference, a cycle that would close between
two draft rows) shows up immediately, before Apply, not just after a
failed commit.
- **A real gap found while building the preview, not by design**: two rows
typed with the same name would otherwise silently collapse — `set`
upserts by name, so the buffer's later row would just overwrite the
earlier one with no indication anything was discarded. Added
`duplicate_name_rows` (checked first, before any `set` call, by both the
preview and the real Apply) to flag every row sharing a duplicated name as
its own error instead of silently picking one arbitrarily.
- **The other half of stage 4 — "a way to pick a named parameter instead of
a literal"**: resolved as letting the *same* value prompt accept either,
rather than building a separate picker UI. `DistanceConstraintCommand`/
`AngleConstraintCommand` (`src/modules/draw/constrain/value.rs`) now take
a one-time snapshot of the active tab's parameter names at construction
(`known_param_names` — the same `default_value`-is-a-one-time-snapshot
precedent these commands already relied on, since `on_text_input` has no
document access); `parse_driving_value` tries a numeric literal first,
falls back to an exact match against that snapshot, and returns neither if
the typed token is unrecognized (the prompt's existing "invalid input"
behavior, unchanged). This mirrors how AutoCAD's own dimensional-
constraint prompts already accept an expression in place of a bare value,
and needed no new interaction design. `CmdResult::AddSketchConstraint`'s
`driving_param` (left as a bare `Option<f64>` in stage 3, deliberately,
exactly so this stage could revisit it) is now `Option<DrivingValue>`.
- **Apply re-solves what it affects**: because editing a named parameter is
the entire point of the feature ("editing the named value ripples to
every constraint that references it," §1), Apply doesn't just rebuild the
table — it also gathers every entity handle touched by an enabled
constraint whose `driving_param` is `DrivingValue::Named` (across every
scope; the whole table changed, not one isolated value, so there's no
cheaper "which handles actually changed" test worth doing) and runs them
through the same `begin_undo`/`bump_entities`/`commit_undo_delta` bracket
`resolve_one_sketch_conflict` already established
(`OpenCADStudio::apply_named_parameter_editor_rows`, `command_driver.rs`).
- **Judgment call on undo granularity (§5 open question 3), not fully
resolved**: the geometry Apply moves is one undo step (bracketed as
above), matching how every other solve-triggering action in this codebase
behaves. The parameter *table* edit itself has no undo entry of its own
yet — that would need a new `HistorySnapshot` variant mirroring
`SketchConstraints` (the sibling project's own stage 9), which this pass
didn't build. Documented, not silently dropped, exactly like stage 9's own
"two presses, not one" scope-down: undoing after an Apply undoes the
geometry it moved, not the parameter values.
- 15 new tests: 3 for `parse_driving_value` (literal-first, name match,
unrecognized token), 6 for the panel's `preview` (cross-row reference,
forward reference regardless of buffer order, a cycle flagged on the row
that closes it, a blank row left unflagged, a malformed formula, the
duplicate-name fix), and 6 integration tests in `command_driver.rs`
(applying rows defines the parameter *and* re-solves a constraint that
already referenced it by name — including re-applying after redefining the
parameter; a malformed row is reported as an error and doesn't block a
good row alongside it; the duplicate-name fix exercised end to end through
`apply_named_parameter_editor_rows`, not just the UI-layer preview).
- **Testing-methodology note carried over from stage 3**: verified with
`cargo test --workspace --no-fail-fast` throughout this stage too, for the
same reason noted there.
Full regression check: `cargo test --workspace --no-fail-fast`, 703 tests
passing, only the same pre-existing, unrelated font-fallback failure noted
throughout this document and its sibling. `cargo clippy --lib --tests` shows
no new warnings on any line this stage touched.
**Not live-verified in the running app this pass** — a real gap, not a
formality skipped by habit: this is genuinely new UI surface (a whole modal
panel, plus a new branch in an existing text-input prompt), and static
review/unit tests can't catch every real-app integration issue (layout,
widget-API misuse that only manifests at runtime, focus/keyboard handling).
Per `docs/backlog.md`'s own live-testing notes, exercising it for real needs
a release build swapped into a scratch copy of the installed app bundle
(`open_application`/`request_access` always resolve to the `/Applications`
copy, and the debug binary doesn't reliably present a controllable window in
this environment) — a real time investment this pass didn't spend, flagged
explicitly rather than silently assumed to work. First things to check in a
future live-testing pass: the panel actually opens via `PARAMETERS`, the
value column updates live while typing, the duplicate/cycle/malformed-
formula error text renders legibly, Apply visibly moves geometry for a
constraint driven by a named parameter, and typing a parameter's name at a
`DCONSTRAINT`/`ACONSTRAINT` prompt is actually accepted.
## Follow-up work not yet done
- **Solid-history `PROP_*` values** (§2's own flagged scope decision, open
question 1) — named parameters currently only reach `SketchConstraint::
driving_param` (Distance/Angle/Radius), not `solid_history.rs`'s box
length/cylinder radius/etc. Not started; a decision to make, not assumed.
- **Constraint-table-edit undo** (open question 3, only partially resolved —
see above).
- **Reserved-word/name-collision UX polish**: `ParameterTable::set` already
rejects an invalid or builtin-colliding name (stage 1); the panel surfaces
that rejection as the row's error text via `preview`/Apply, but there's no
live per-keystroke nudge beyond that (e.g. graying out `Apply` while any
row has an error) — a small, optional polish, not a functional gap.
- **Live UI verification**, per the note above.
File diff suppressed because it is too large. Load diff