Merge pull request 'docs(solver): update drag protocol docs to reflect implemented caching' (#330) from docs/solver-drag-cache into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Successful in 29m53s

Reviewed-on: #330
This commit was merged in pull request #330.
This commit is contained in:
2026-02-26 14:25:17 +00:00

View File

@@ -98,53 +98,107 @@ if hasattr(FreeCADGui, "ActiveDocument"):
## Interactive drag protocol ## 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) ### 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 ```python
def pre_drag(self, ctx, drag_parts): def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx self._drag_ctx = ctx
self._drag_parts = set(drag_parts) 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) ### 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 ```python
def drag_step(self, drag_placements): 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 pr in drag_placements:
for part in ctx.parts: pfx = pr.id + "/"
if part.id == pr.id: params.set_value(pfx + "tx", pr.placement.position[0])
part.placement = pr.placement params.set_value(pfx + "ty", pr.placement.position[1])
break params.set_value(pfx + "tz", pr.placement.position[2])
return self.solve(ctx) 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() ### post_drag()
Called when the drag ends. Clears the stored state. Called when the drag ends. Clears the cached state.
```python ```python
def post_drag(self): def post_drag(self):
self._drag_ctx = None self._drag_ctx = None
self._drag_parts = 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 - Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- Pre-passes eliminate fixed parameters before the iterative loop - The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
- The symbolic Jacobian is recomputed each step (no caching yet) - 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`)
For larger assemblies, cached incremental solving (reusing the decomposition and Jacobian structure across drag steps) is planned as a future optimization.
## Diagnostics integration ## Diagnostics integration