docs(solver): update drag protocol docs to reflect implemented caching

The interactive drag section described the original naive implementation
(re-solve from scratch each step) and called the caching layer a
'planned future optimization'. In reality _DragCache is fully
implemented: pre_drag() builds the system, Jacobian, and compiled
evaluator once, and drag_step() reuses them.

Update code snippets, add _DragCache field table, and document the
single_equation_pass exclusion from the drag path.
This commit is contained in:
2026-02-25 13:16:51 -06:00
parent 314955c3ef
commit fa644fc0d4

View File

@@ -98,53 +98,107 @@ if hasattr(FreeCADGui, "ActiveDocument"):
## Interactive drag protocol
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol:
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
### pre_drag(ctx, drag_parts)
Called when the user begins dragging. Stores the context and dragged part IDs, then runs a full solve to establish the starting state.
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a `_DragCache` for reuse across subsequent `drag_step()` calls.
```python
def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
return self.solve(ctx)
system = _build_system(ctx)
half_spaces = compute_half_spaces(...)
weight_vec = build_weight_vector(system.params)
residuals = substitution_pass(system.all_residuals, system.params)
# single_equation_pass is intentionally skipped — it bakes variable
# values as constants that become stale when dragged parts move.
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
# Initial solve (Newton-Raphson + BFGS fallback)
newton_solve(residuals, system.params, ...)
# Cache for drag_step() reuse
cache = _DragCache()
cache.system = system
cache.residuals = residuals
cache.jac_exprs = jac_exprs
cache.compiled_eval = compiled_eval
cache.half_spaces = half_spaces
cache.weight_vec = weight_vec
...
return result
```
**Important:** `single_equation_pass` is not used in the drag path. It analytically solves single-variable equations and bakes the results as `Const()` nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only `substitution_pass` (which replaces genuinely grounded parameters) is safe to cache.
### drag_step(drag_placements)
Called on each mouse move. Updates the dragged parts' placements in the stored context, then re-solves. Since the parts moved only slightly from the previous position, Newton-Raphson converges in 1-2 iterations.
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached `ParamTable`, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
```python
def drag_step(self, drag_placements):
ctx = self._drag_ctx
cache = self._drag_cache
params = cache.system.params
# Update only the dragged part's parameters
for pr in drag_placements:
for part in ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
return self.solve(ctx)
pfx = pr.id + "/"
params.set_value(pfx + "tx", pr.placement.position[0])
params.set_value(pfx + "ty", pr.placement.position[1])
params.set_value(pfx + "tz", pr.placement.position[2])
params.set_value(pfx + "qw", pr.placement.quaternion[0])
params.set_value(pfx + "qx", pr.placement.quaternion[1])
params.set_value(pfx + "qy", pr.placement.quaternion[2])
params.set_value(pfx + "qz", pr.placement.quaternion[3])
# Solve with cached artifacts — no rebuild
newton_solve(cache.residuals, params, ...,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval)
return result
```
### post_drag()
Called when the drag ends. Clears the stored state.
Called when the drag ends. Clears the cached state.
```python
def post_drag(self):
self._drag_ctx = None
self._drag_parts = None
self._drag_cache = None
```
### Performance notes
### _DragCache
The current implementation re-solves from scratch on each drag step, using the updated placements as the initial guess. This is correct and simple. For assemblies with fewer than ~50 parts, interactive frame rates are maintained because:
The cache holds all artifacts built in `pre_drag()` that are invariant across drag steps (constraint topology doesn't change during a drag):
| Field | Contents |
|-------|----------|
| `system` | `_System` -- owns `ParamTable` and `Expr` trees |
| `residuals` | `list[Expr]` -- after substitution pass |
| `jac_exprs` | `list[list[Expr]]` -- symbolic Jacobian |
| `compiled_eval` | `Callable` or `None` -- native compiled evaluator |
| `half_spaces` | `list[HalfSpace]` -- branch trackers |
| `weight_vec` | `ndarray` or `None` -- minimum-movement weights |
| `post_step_fn` | `Callable` or `None` -- half-space correction callback |
### Performance
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each `drag_step()` only evaluates the cached expressions at updated parameter values:
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- Pre-passes eliminate fixed parameters before the iterative loop
- The symbolic Jacobian is recomputed each step (no caching yet)
For larger assemblies, cached incremental solving (reusing the decomposition and Jacobian structure across drag steps) is planned as a future optimization.
- The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
- The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
- DOF counting is skipped during drag for speed (`result.dof = -1`)
## Diagnostics integration