docs(solver): update drag protocol docs to reflect implemented caching
All checks were successful
Build and Test / build (pull_request) Successful in 30m27s
All checks were successful
Build and Test / build (pull_request) Successful in 30m27s
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user