bug(assembly): planar constraint drifts during interactive drag with combined rotational joints #338

Closed
opened 2026-02-27 14:55:14 +00:00 by forbes · 1 comment
Owner

Summary

During interactive drag of a body constrained by a Planar joint + Cylindrical joint (rotation about the assembly origin Z-axis, coplanar on XY), the constrained part drifts away from the constraint plane. The 91-degree orientation validator fires and rejects subsequent drag steps, effectively freezing or "exploding" the part.

This bug persists across both solver backends (OndselSolver and Kindred Newton-Raphson), indicating the root cause is upstream of the solver selection — likely in the drag protocol, constraint formulation, or placement validation layer.

Steps to reproduce

  1. Create an assembly with two bodies
  2. Add a Cylindrical joint about the assembly origin Z-axis
  3. Add a Planar joint (distance = 0) mating the bar's origin XY plane to the assembly's fixed origin XY plane
  4. Enter drag mode and rotate the constrained body around the cylinder axis
  5. Observe: after approximately 90 degrees of rotation, the part drifts off the constraint plane and/or the console logs "Assembly: Ignoring bad solve, part (...) flipped orientation (N degrees)"

Expected behaviour

The part should remain coplanar with the fixed XY plane throughout the full 360-degree drag rotation, smoothly orbiting the Z-axis while the planar constraint holds.

Actual behaviour

The part progressively drifts off-plane during drag. The C++ validateNewPlacements() 91-degree threshold eventually fires, rejecting drag steps. The part appears to freeze or jump erratically.

Analysis

Prior fix attempts

Commit Branch What it tried Result
9dbb1bc fix/assembly-jcs-visual-drag-robust Improved half-space detection for planar constraints during drag Partial — reduced frequency but did not eliminate
41c91c4 fix/assembly-ondsel-lock Pinned OndselSolver to 3a8f2c1 after GCS planar regression Stabilised OndselSolver but drift persisted
ac0d2e8 fix/assembly-stabilize-undo Stabilised Distance joint and undo/redo baseline updates Helped undo consistency but not drag drift
fa34dc0 (mods/solver submodule) Removed half-space correction_fn for on-plane (distance=0) planar constraints Fixed the Kindred solver's branch-tracker teleporting the body, but drift still occurs
7e2b4f9 fix/planar-halfspace-drag-flip (OndselSolver) Fixed half-space sign when drag vector opposes constraint normal Addressed a specific sign error but drift persists

Architecture context

The drag system uses a three-phase protocol:

pre_drag(ctx, drag_parts)  -> initial solve + cache setup
drag_step(drag_placements) -> incremental solve per mouse move
post_drag()                -> cleanup

The GUI computes absolute placements (not deltas) and passes them to the solver. The solver resolves all non-dragged parts to satisfy constraints, then the C++ validator checks that no part rotated more than 91 degrees from the per-step baseline.

Why both solvers fail

  • OndselSolver (C++): Uses direction-cosine constraints (z_I . x_J = 0, z_I . y_J = 0) which are satisfied by both z_I = +z_J and z_I = -z_J. The Lagrangian solver has no inherent preference for which branch and converges to the nearest solution from the initial guess. No half-space tracking exists at the C++ level.

  • Kindred Solver (Python): Uses cross-product residuals (z_I x z_J = 0) plus point-plane distance. After the fa34dc0 fix, the half-space correction is disabled for distance=0 planar constraints (to avoid fighting legitimate rotation). But the solver can still converge to the anti-aligned normal branch.

Root cause hypothesis

The constraint formulation for Planar + Cylindrical combined is under-determined in normal direction when the dragged body rotates through 90 degrees. At that point dot(z_body, z_fixed) ~ 0, and both normal orientations are equidistant solutions. The solver picks one arbitrarily. If it picks the anti-aligned branch, the translation constraint (p_i - p_j) . z_j = 0 is satisfied on the opposite side of the plane.

The key question is whether the initial guess (seeded from the previous accepted drag step) is sufficient to keep the solver in the correct branch, or whether the Jacobian becomes singular/ill-conditioned near the 90-degree crossing and the solver drifts across the branch boundary.

Possible fix directions

  1. Add an explicit normal-alignment constraint — instead of just z_I perp x_J and z_I perp y_J, add z_I . z_J > 0 (inequality or soft penalty) to break the sign ambiguity
  2. Track and correct at the drag protocol level — after each drag_step(), verify dot(z_body_face, z_fixed_plane) > 0 and negate the normal if it flipped, before passing to validateNewPlacements()
  3. Use signed direction cosines — replace z_I . x_J = 0 with constraints that distinguish aligned vs anti-aligned normals (e.g., z_I . z_J = 1 instead of relying on two perpendicularity constraints)
  4. Reduce step size near singularity — detect when |dot(z_I, z_J)| is near zero and subdivide the drag step to maintain solver convergence in the correct branch
  5. Investigate the drag mode — for a Cylindrical+Planar combination, the drag mode should be RotationOnPlane, which computes rotation angles. Verify the rotation is applied correctly and the seeded placement does not inadvertently cross the branch boundary

Environment

  • Kindred Create 0.1.5
  • Linux x86_64
  • Reproducible with both OndselSolver and Kindred solver backends
  • #254 — Original Planar joint implementation
  • #298 — JCS visual feedback and constraint drag robustness
  • #305 — Distance joint and undo/redo stabilisation
  • Branch fix/planar-halfspace-drag-flip — latest attempted fix (OndselSolver submodule update)
## Summary During interactive drag of a body constrained by a **Planar joint + Cylindrical joint** (rotation about the assembly origin Z-axis, coplanar on XY), the constrained part drifts away from the constraint plane. The 91-degree orientation validator fires and rejects subsequent drag steps, effectively freezing or "exploding" the part. This bug persists across **both solver backends** (OndselSolver and Kindred Newton-Raphson), indicating the root cause is upstream of the solver selection — likely in the drag protocol, constraint formulation, or placement validation layer. ## Steps to reproduce 1. Create an assembly with two bodies 2. Add a **Cylindrical joint** about the assembly origin Z-axis 3. Add a **Planar joint** (distance = 0) mating the bar's origin XY plane to the assembly's fixed origin XY plane 4. Enter drag mode and rotate the constrained body around the cylinder axis 5. **Observe:** after approximately 90 degrees of rotation, the part drifts off the constraint plane and/or the console logs `"Assembly: Ignoring bad solve, part (...) flipped orientation (N degrees)"` ## Expected behaviour The part should remain coplanar with the fixed XY plane throughout the full 360-degree drag rotation, smoothly orbiting the Z-axis while the planar constraint holds. ## Actual behaviour The part progressively drifts off-plane during drag. The C++ `validateNewPlacements()` 91-degree threshold eventually fires, rejecting drag steps. The part appears to freeze or jump erratically. ## Analysis ### Prior fix attempts | Commit | Branch | What it tried | Result | |--------|--------|---------------|--------| | `9dbb1bc` | `fix/assembly-jcs-visual-drag-robust` | Improved half-space detection for planar constraints during drag | Partial — reduced frequency but did not eliminate | | `41c91c4` | `fix/assembly-ondsel-lock` | Pinned OndselSolver to `3a8f2c1` after GCS planar regression | Stabilised OndselSolver but drift persisted | | `ac0d2e8` | `fix/assembly-stabilize-undo` | Stabilised Distance joint and undo/redo baseline updates | Helped undo consistency but not drag drift | | `fa34dc0` | (mods/solver submodule) | Removed half-space correction_fn for on-plane (distance=0) planar constraints | Fixed the Kindred solver's branch-tracker teleporting the body, but drift still occurs | | `7e2b4f9` | `fix/planar-halfspace-drag-flip` (OndselSolver) | Fixed half-space sign when drag vector opposes constraint normal | Addressed a specific sign error but drift persists | ### Architecture context The drag system uses a three-phase protocol: ``` pre_drag(ctx, drag_parts) -> initial solve + cache setup drag_step(drag_placements) -> incremental solve per mouse move post_drag() -> cleanup ``` The GUI computes **absolute placements** (not deltas) and passes them to the solver. The solver resolves all non-dragged parts to satisfy constraints, then the C++ validator checks that no part rotated more than 91 degrees from the **per-step baseline**. ### Why both solvers fail - **OndselSolver (C++):** Uses direction-cosine constraints (`z_I . x_J = 0`, `z_I . y_J = 0`) which are satisfied by both `z_I = +z_J` and `z_I = -z_J`. The Lagrangian solver has no inherent preference for which branch and converges to the nearest solution from the initial guess. No half-space tracking exists at the C++ level. - **Kindred Solver (Python):** Uses cross-product residuals (`z_I x z_J = 0`) plus point-plane distance. After the `fa34dc0` fix, the half-space correction is disabled for distance=0 planar constraints (to avoid fighting legitimate rotation). But the solver can still converge to the anti-aligned normal branch. ### Root cause hypothesis The constraint formulation for **Planar + Cylindrical** combined is **under-determined in normal direction** when the dragged body rotates through 90 degrees. At that point `dot(z_body, z_fixed) ~ 0`, and both normal orientations are equidistant solutions. The solver picks one arbitrarily. If it picks the anti-aligned branch, the translation constraint `(p_i - p_j) . z_j = 0` is satisfied on the **opposite side** of the plane. The key question is whether the **initial guess** (seeded from the previous accepted drag step) is sufficient to keep the solver in the correct branch, or whether the **Jacobian becomes singular/ill-conditioned** near the 90-degree crossing and the solver drifts across the branch boundary. ### Possible fix directions 1. **Add an explicit normal-alignment constraint** — instead of just `z_I perp x_J` and `z_I perp y_J`, add `z_I . z_J > 0` (inequality or soft penalty) to break the sign ambiguity 2. **Track and correct at the drag protocol level** — after each `drag_step()`, verify `dot(z_body_face, z_fixed_plane) > 0` and negate the normal if it flipped, before passing to `validateNewPlacements()` 3. **Use signed direction cosines** — replace `z_I . x_J = 0` with constraints that distinguish aligned vs anti-aligned normals (e.g., `z_I . z_J = 1` instead of relying on two perpendicularity constraints) 4. **Reduce step size near singularity** — detect when `|dot(z_I, z_J)|` is near zero and subdivide the drag step to maintain solver convergence in the correct branch 5. **Investigate the drag mode** — for a Cylindrical+Planar combination, the drag mode should be `RotationOnPlane`, which computes rotation angles. Verify the rotation is applied correctly and the seeded placement does not inadvertently cross the branch boundary ## Environment - Kindred Create 0.1.5 - Linux x86_64 - Reproducible with both OndselSolver and Kindred solver backends ## Related issues / PRs - #254 — Original Planar joint implementation - #298 — JCS visual feedback and constraint drag robustness - #305 — Distance joint and undo/redo stabilisation - Branch `fix/planar-halfspace-drag-flip` — latest attempted fix (OndselSolver submodule update)
forbes added the bug label 2026-02-27 14:55:14 +00:00
Author
Owner

Fixed, next we should investigate more assembly arrangements to understand if there are further quaternion discontinuities in our residuals.

Fixed, next we should investigate more assembly arrangements to understand if there are further quaternion discontinuities in our residuals.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/create#338