From 6e15b25134e2c5227ac617922e812f01532807e6 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Wed, 25 Feb 2026 13:16:51 -0600 Subject: [PATCH] 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. --- docs/src/solver/assembly-integration.md | 88 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/docs/src/solver/assembly-integration.md b/docs/src/solver/assembly-integration.md index b3c5a5b32e..feb9738aa0 100644 --- a/docs/src/solver/assembly-integration.md +++ b/docs/src/solver/assembly-integration.md @@ -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