Compare commits
8 Commits
fix/cross-
...
fix/planar
| Author | SHA1 | Date | |
|---|---|---|---|
| 000f54adaa | |||
|
|
85a607228d | ||
| 6c2ddb6494 | |||
| 5802d45a7f | |||
| 9d86bb203e | |||
|
|
c2ebcc3169 | ||
| e7e4266f3d | |||
|
|
0825578778 |
@@ -416,6 +416,7 @@ class PlanarConstraint(ConstraintBase):
|
||||
self.marker_j_pos = marker_j_pos
|
||||
self.marker_j_quat = marker_j_quat
|
||||
self.offset = offset
|
||||
self.reference_normal = None # Set by _build_system; world-frame Const normal
|
||||
|
||||
def residuals(self) -> List[Expr]:
|
||||
# Parallel normals (3 cross-product residuals, rank 2 at solution)
|
||||
@@ -423,10 +424,20 @@ class PlanarConstraint(ConstraintBase):
|
||||
z_j = marker_z_axis(self.body_j, self.marker_j_quat)
|
||||
cx, cy, cz = cross3(z_i, z_j)
|
||||
|
||||
# Point-in-plane
|
||||
# Point-in-plane distance — use world-anchored reference normal when
|
||||
# available so the constraining plane direction doesn't rotate with
|
||||
# the body (prevents axial drift when combined with Cylindrical).
|
||||
p_i = self.body_i.world_point(*self.marker_i_pos)
|
||||
p_j = self.body_j.world_point(*self.marker_j_pos)
|
||||
d = point_plane_distance(p_i, p_j, z_j)
|
||||
if self.reference_normal is not None:
|
||||
nz = (
|
||||
Const(self.reference_normal[0]),
|
||||
Const(self.reference_normal[1]),
|
||||
Const(self.reference_normal[2]),
|
||||
)
|
||||
else:
|
||||
nz = z_j
|
||||
d = point_plane_distance(p_i, p_j, nz)
|
||||
if self.offset != 0.0:
|
||||
d = d - Const(self.offset)
|
||||
|
||||
|
||||
@@ -21,12 +21,21 @@ import numpy as np
|
||||
|
||||
from .constraints import (
|
||||
AngleConstraint,
|
||||
ConcentricConstraint,
|
||||
ConstraintBase,
|
||||
CylindricalConstraint,
|
||||
DistancePointPointConstraint,
|
||||
LineInPlaneConstraint,
|
||||
ParallelConstraint,
|
||||
PerpendicularConstraint,
|
||||
PlanarConstraint,
|
||||
PointInPlaneConstraint,
|
||||
RevoluteConstraint,
|
||||
ScrewConstraint,
|
||||
SliderConstraint,
|
||||
UniversalConstraint,
|
||||
)
|
||||
from .geometry import cross3, dot3, marker_z_axis
|
||||
from .geometry import cross3, dot3, marker_z_axis, point_plane_distance, sub3
|
||||
from .params import ParamTable
|
||||
|
||||
|
||||
@@ -107,6 +116,33 @@ def _build_half_space(
|
||||
if isinstance(obj, PerpendicularConstraint):
|
||||
return _perpendicular_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, PlanarConstraint):
|
||||
return _planar_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, RevoluteConstraint):
|
||||
return _revolute_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, ConcentricConstraint):
|
||||
return _concentric_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, PointInPlaneConstraint):
|
||||
return _point_in_plane_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, LineInPlaneConstraint):
|
||||
return _line_in_plane_half_space(obj, constraint_idx, env, params)
|
||||
|
||||
if isinstance(obj, CylindricalConstraint):
|
||||
return _axis_direction_half_space(obj, constraint_idx, env)
|
||||
|
||||
if isinstance(obj, SliderConstraint):
|
||||
return _axis_direction_half_space(obj, constraint_idx, env)
|
||||
|
||||
if isinstance(obj, ScrewConstraint):
|
||||
return _axis_direction_half_space(obj, constraint_idx, env)
|
||||
|
||||
if isinstance(obj, UniversalConstraint):
|
||||
return _universal_half_space(obj, constraint_idx, env)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -212,6 +248,319 @@ def _parallel_half_space(
|
||||
)
|
||||
|
||||
|
||||
def _planar_half_space(
|
||||
obj: PlanarConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
params: ParamTable,
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for Planar: track which side of the plane the point is on
|
||||
AND which direction the normals face.
|
||||
|
||||
A Planar constraint has parallel normals (cross product = 0) plus
|
||||
point-in-plane (signed distance = 0). Both have branch ambiguity:
|
||||
the normals can be same-direction or opposite, and the point can
|
||||
approach the plane from either side. We track the signed distance
|
||||
from marker_i to the plane defined by marker_j — this captures
|
||||
the plane-side and is the primary drift mode during drag.
|
||||
"""
|
||||
# Point-in-plane signed distance as indicator
|
||||
p_i = obj.body_i.world_point(*obj.marker_i_pos)
|
||||
p_j = obj.body_j.world_point(*obj.marker_j_pos)
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
d_expr = point_plane_distance(p_i, p_j, z_j)
|
||||
|
||||
d_val = d_expr.eval(env)
|
||||
|
||||
# Also track normal alignment (same as Parallel half-space)
|
||||
z_i = marker_z_axis(obj.body_i, obj.marker_i_quat)
|
||||
dot_expr = dot3(z_i, z_j)
|
||||
dot_val = dot_expr.eval(env)
|
||||
normal_ref_sign = math.copysign(1.0, dot_val) if abs(dot_val) > 1e-14 else 1.0
|
||||
|
||||
# If offset is zero and distance is near-zero, we still need the normal
|
||||
# direction indicator to prevent flipping through the plane.
|
||||
# Use the normal dot product as the primary indicator when the point
|
||||
# is already on the plane (distance ≈ 0).
|
||||
if abs(d_val) < 1e-10:
|
||||
# Point is on the plane — track normal direction only.
|
||||
# No correction: when combined with rotational joints (Cylindrical,
|
||||
# Revolute), the body's marker normal rotates legitimately and
|
||||
# reflecting through the plane would fight the rotation.
|
||||
def indicator(e: dict[str, float]) -> float:
|
||||
return dot_expr.eval(e)
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=normal_ref_sign,
|
||||
indicator_fn=indicator,
|
||||
)
|
||||
else:
|
||||
# Point is off-plane — track which side
|
||||
def indicator(e: dict[str, float]) -> float:
|
||||
return d_expr.eval(e)
|
||||
|
||||
ref_sign = math.copysign(1.0, d_val)
|
||||
|
||||
# Correction: reflect the moving body's position through the plane
|
||||
moving_body = obj.body_j if not obj.body_j.grounded else obj.body_i
|
||||
if moving_body.grounded:
|
||||
return None
|
||||
|
||||
px_name = f"{moving_body.part_id}/tx"
|
||||
py_name = f"{moving_body.part_id}/ty"
|
||||
pz_name = f"{moving_body.part_id}/tz"
|
||||
|
||||
def correction(p: ParamTable, _val: float) -> None:
|
||||
e = p.get_env()
|
||||
# Recompute signed distance and normal direction
|
||||
d_cur = d_expr.eval(e)
|
||||
nx = z_j[0].eval(e)
|
||||
ny = z_j[1].eval(e)
|
||||
nz = z_j[2].eval(e)
|
||||
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||
if n_len < 1e-15:
|
||||
return
|
||||
nx, ny, nz = nx / n_len, ny / n_len, nz / n_len
|
||||
# Reflect through plane: move body by -2*d along normal
|
||||
sign = -1.0 if moving_body is obj.body_j else 1.0
|
||||
if not p.is_fixed(px_name):
|
||||
p.set_value(px_name, p.get_value(px_name) + sign * 2.0 * d_cur * nx)
|
||||
if not p.is_fixed(py_name):
|
||||
p.set_value(py_name, p.get_value(py_name) + sign * 2.0 * d_cur * ny)
|
||||
if not p.is_fixed(pz_name):
|
||||
p.set_value(pz_name, p.get_value(pz_name) + sign * 2.0 * d_cur * nz)
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=indicator,
|
||||
correction_fn=correction,
|
||||
)
|
||||
|
||||
|
||||
def _revolute_half_space(
|
||||
obj: RevoluteConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
params: ParamTable,
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for Revolute: track hinge axis direction.
|
||||
|
||||
A revolute has coincident origins + parallel Z-axes. The parallel
|
||||
axes can flip direction (same ambiguity as Parallel). Track
|
||||
dot(z_i, z_j) to prevent the axis from inverting.
|
||||
"""
|
||||
z_i = marker_z_axis(obj.body_i, obj.marker_i_quat)
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
dot_expr = dot3(z_i, z_j)
|
||||
|
||||
ref_val = dot_expr.eval(env)
|
||||
ref_sign = math.copysign(1.0, ref_val) if abs(ref_val) > 1e-14 else 1.0
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: dot_expr.eval(e),
|
||||
)
|
||||
|
||||
|
||||
def _concentric_half_space(
|
||||
obj: ConcentricConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
params: ParamTable,
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for Concentric: track axis direction.
|
||||
|
||||
Concentric has parallel axes + point-on-line. The parallel axes
|
||||
can flip direction. Track dot(z_i, z_j).
|
||||
"""
|
||||
z_i = marker_z_axis(obj.body_i, obj.marker_i_quat)
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
dot_expr = dot3(z_i, z_j)
|
||||
|
||||
ref_val = dot_expr.eval(env)
|
||||
ref_sign = math.copysign(1.0, ref_val) if abs(ref_val) > 1e-14 else 1.0
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: dot_expr.eval(e),
|
||||
)
|
||||
|
||||
|
||||
def _point_in_plane_half_space(
|
||||
obj: PointInPlaneConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
params: ParamTable,
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for PointInPlane: track which side of the plane.
|
||||
|
||||
The signed distance to the plane can be satisfied from either side.
|
||||
Track which side the initial configuration is on.
|
||||
"""
|
||||
p_i = obj.body_i.world_point(*obj.marker_i_pos)
|
||||
p_j = obj.body_j.world_point(*obj.marker_j_pos)
|
||||
n_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
d_expr = point_plane_distance(p_i, p_j, n_j)
|
||||
|
||||
d_val = d_expr.eval(env)
|
||||
if abs(d_val) < 1e-10:
|
||||
return None # already on the plane, no branch to track
|
||||
|
||||
ref_sign = math.copysign(1.0, d_val)
|
||||
|
||||
moving_body = obj.body_j if not obj.body_j.grounded else obj.body_i
|
||||
if moving_body.grounded:
|
||||
return None
|
||||
|
||||
px_name = f"{moving_body.part_id}/tx"
|
||||
py_name = f"{moving_body.part_id}/ty"
|
||||
pz_name = f"{moving_body.part_id}/tz"
|
||||
|
||||
def correction(p: ParamTable, _val: float) -> None:
|
||||
e = p.get_env()
|
||||
d_cur = d_expr.eval(e)
|
||||
nx = n_j[0].eval(e)
|
||||
ny = n_j[1].eval(e)
|
||||
nz = n_j[2].eval(e)
|
||||
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||
if n_len < 1e-15:
|
||||
return
|
||||
nx, ny, nz = nx / n_len, ny / n_len, nz / n_len
|
||||
sign = -1.0 if moving_body is obj.body_j else 1.0
|
||||
if not p.is_fixed(px_name):
|
||||
p.set_value(px_name, p.get_value(px_name) + sign * 2.0 * d_cur * nx)
|
||||
if not p.is_fixed(py_name):
|
||||
p.set_value(py_name, p.get_value(py_name) + sign * 2.0 * d_cur * ny)
|
||||
if not p.is_fixed(pz_name):
|
||||
p.set_value(pz_name, p.get_value(pz_name) + sign * 2.0 * d_cur * nz)
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: d_expr.eval(e),
|
||||
correction_fn=correction,
|
||||
)
|
||||
|
||||
|
||||
def _line_in_plane_half_space(
|
||||
obj: LineInPlaneConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
params: ParamTable,
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for LineInPlane: track which side of the plane.
|
||||
|
||||
Same plane-side ambiguity as PointInPlane.
|
||||
"""
|
||||
p_i = obj.body_i.world_point(*obj.marker_i_pos)
|
||||
p_j = obj.body_j.world_point(*obj.marker_j_pos)
|
||||
n_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
d_expr = point_plane_distance(p_i, p_j, n_j)
|
||||
|
||||
d_val = d_expr.eval(env)
|
||||
if abs(d_val) < 1e-10:
|
||||
return None
|
||||
|
||||
ref_sign = math.copysign(1.0, d_val)
|
||||
|
||||
moving_body = obj.body_j if not obj.body_j.grounded else obj.body_i
|
||||
if moving_body.grounded:
|
||||
return None
|
||||
|
||||
px_name = f"{moving_body.part_id}/tx"
|
||||
py_name = f"{moving_body.part_id}/ty"
|
||||
pz_name = f"{moving_body.part_id}/tz"
|
||||
|
||||
def correction(p: ParamTable, _val: float) -> None:
|
||||
e = p.get_env()
|
||||
d_cur = d_expr.eval(e)
|
||||
nx = n_j[0].eval(e)
|
||||
ny = n_j[1].eval(e)
|
||||
nz = n_j[2].eval(e)
|
||||
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||
if n_len < 1e-15:
|
||||
return
|
||||
nx, ny, nz = nx / n_len, ny / n_len, nz / n_len
|
||||
sign = -1.0 if moving_body is obj.body_j else 1.0
|
||||
if not p.is_fixed(px_name):
|
||||
p.set_value(px_name, p.get_value(px_name) + sign * 2.0 * d_cur * nx)
|
||||
if not p.is_fixed(py_name):
|
||||
p.set_value(py_name, p.get_value(py_name) + sign * 2.0 * d_cur * ny)
|
||||
if not p.is_fixed(pz_name):
|
||||
p.set_value(pz_name, p.get_value(pz_name) + sign * 2.0 * d_cur * nz)
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: d_expr.eval(e),
|
||||
correction_fn=correction,
|
||||
)
|
||||
|
||||
|
||||
def _axis_direction_half_space(
|
||||
obj,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for any constraint with parallel Z-axes (Cylindrical, Slider, Screw).
|
||||
|
||||
Tracks dot(z_i, z_j) to prevent axis inversion.
|
||||
"""
|
||||
z_i = marker_z_axis(obj.body_i, obj.marker_i_quat)
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
dot_expr = dot3(z_i, z_j)
|
||||
|
||||
ref_val = dot_expr.eval(env)
|
||||
ref_sign = math.copysign(1.0, ref_val) if abs(ref_val) > 1e-14 else 1.0
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: dot_expr.eval(e),
|
||||
)
|
||||
|
||||
|
||||
def _universal_half_space(
|
||||
obj: UniversalConstraint,
|
||||
constraint_idx: int,
|
||||
env: dict[str, float],
|
||||
) -> HalfSpace | None:
|
||||
"""Half-space for Universal: track which quadrant of perpendicularity.
|
||||
|
||||
Universal has dot(z_i, z_j) = 0 (perpendicular). The cross product
|
||||
sign distinguishes which "side" of perpendicular.
|
||||
"""
|
||||
z_i = marker_z_axis(obj.body_i, obj.marker_i_quat)
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
cx, cy, cz = cross3(z_i, z_j)
|
||||
|
||||
cx_val = cx.eval(env)
|
||||
cy_val = cy.eval(env)
|
||||
cz_val = cz.eval(env)
|
||||
|
||||
components = [
|
||||
(abs(cx_val), cx, cx_val),
|
||||
(abs(cy_val), cy, cy_val),
|
||||
(abs(cz_val), cz, cz_val),
|
||||
]
|
||||
_, best_expr, best_val = max(components, key=lambda t: t[0])
|
||||
|
||||
if abs(best_val) < 1e-14:
|
||||
return None
|
||||
|
||||
ref_sign = math.copysign(1.0, best_val)
|
||||
|
||||
return HalfSpace(
|
||||
constraint_index=constraint_idx,
|
||||
reference_sign=ref_sign,
|
||||
indicator_fn=lambda e: best_expr.eval(e),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Minimum-movement weighting
|
||||
# ============================================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ expression-based Newton-Raphson solver."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
import kcsolve
|
||||
@@ -38,6 +39,7 @@ from .constraints import (
|
||||
UniversalConstraint,
|
||||
)
|
||||
from .decompose import decompose, solve_decomposed
|
||||
from .geometry import marker_z_axis
|
||||
from .diagnostics import find_overconstrained
|
||||
from .dof import count_dof
|
||||
from .entities import RigidBody
|
||||
@@ -130,8 +132,7 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
for c in ctx.constraints:
|
||||
if c.limits:
|
||||
log.warning(
|
||||
"Joint limits on '%s' ignored "
|
||||
"(not yet supported by Kindred solver)",
|
||||
"Joint limits on '%s' ignored (not yet supported by Kindred solver)",
|
||||
c.id,
|
||||
)
|
||||
self._limits_warned = True
|
||||
@@ -143,8 +144,6 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
system.constraint_indices,
|
||||
system.params,
|
||||
)
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
if half_spaces:
|
||||
post_step_fn = lambda p: apply_half_space_correction(p, half_spaces)
|
||||
else:
|
||||
@@ -154,6 +153,10 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
residuals = single_equation_pass(residuals, system.params)
|
||||
|
||||
# Build weight vector *after* pre-passes so its length matches the
|
||||
# remaining free parameters (single_equation_pass may fix some).
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
# Solve (decomposed for large assemblies, monolithic for small)
|
||||
jac_exprs = None # may be populated by _monolithic_solve
|
||||
if n_free_bodies >= _DECOMPOSE_THRESHOLD:
|
||||
@@ -200,9 +203,7 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
|
||||
# Build result
|
||||
result = kcsolve.SolveResult()
|
||||
result.status = (
|
||||
kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
)
|
||||
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
result.dof = dof
|
||||
|
||||
# Diagnostics on failure
|
||||
@@ -227,9 +228,7 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
)
|
||||
if not converged and result.diagnostics:
|
||||
for d in result.diagnostics:
|
||||
log.warning(
|
||||
" diagnostic: [%s] %s — %s", d.kind, d.constraint_id, d.detail
|
||||
)
|
||||
log.warning(" diagnostic: [%s] %s — %s", d.kind, d.constraint_id, d.detail)
|
||||
|
||||
return result
|
||||
|
||||
@@ -255,7 +254,6 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
system.constraint_indices,
|
||||
system.params,
|
||||
)
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
if half_spaces:
|
||||
post_step_fn = lambda p: apply_half_space_correction(p, half_spaces)
|
||||
@@ -263,7 +261,17 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
post_step_fn = None
|
||||
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
residuals = single_equation_pass(residuals, system.params)
|
||||
# NOTE: single_equation_pass is intentionally skipped for drag.
|
||||
# It permanently fixes variables and removes residuals from the
|
||||
# list. During drag the dragged part's parameters change each
|
||||
# frame, which can invalidate those analytic solutions and cause
|
||||
# constraints (e.g. Planar distance=0) to stop being enforced.
|
||||
# The substitution pass alone is safe because it only replaces
|
||||
# genuinely grounded parameters with constants.
|
||||
|
||||
# Build weight vector *after* pre-passes so its length matches the
|
||||
# remaining free parameters (single_equation_pass may fix some).
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
# Build symbolic Jacobian + compile once
|
||||
from .codegen import try_compile_system
|
||||
@@ -307,14 +315,20 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
cache.half_spaces = half_spaces
|
||||
cache.weight_vec = weight_vec
|
||||
cache.post_step_fn = post_step_fn
|
||||
|
||||
# Snapshot solved quaternions for continuity enforcement in drag_step()
|
||||
env = system.params.get_env()
|
||||
cache.pre_step_quats = {}
|
||||
for body in system.bodies.values():
|
||||
if not body.grounded:
|
||||
cache.pre_step_quats[body.part_id] = body.extract_quaternion(env)
|
||||
|
||||
self._drag_cache = cache
|
||||
|
||||
# Build result
|
||||
dof = count_dof(residuals, system.params, jac_exprs=jac_exprs)
|
||||
result = kcsolve.SolveResult()
|
||||
result.status = (
|
||||
kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
)
|
||||
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
result.dof = dof
|
||||
result.placements = _extract_placements(system.params, system.bodies)
|
||||
|
||||
@@ -388,10 +402,22 @@ class KindredSolver(kcsolve.IKCSolver):
|
||||
compiled_eval=cache.compiled_eval,
|
||||
)
|
||||
|
||||
# Quaternion continuity: ensure solved quaternions stay in the
|
||||
# same hemisphere as the previous step. q and -q encode the
|
||||
# same rotation, but the C++ side measures angle between the
|
||||
# old and new quaternion — if we're in the -q branch, that
|
||||
# shows up as a ~340° flip and gets rejected.
|
||||
dragged_ids = self._drag_parts or set()
|
||||
_enforce_quat_continuity(params, cache.system.bodies, cache.pre_step_quats, dragged_ids)
|
||||
|
||||
# Update the stored quaternions for the next drag step
|
||||
env = params.get_env()
|
||||
for body in cache.system.bodies.values():
|
||||
if not body.grounded:
|
||||
cache.pre_step_quats[body.part_id] = body.extract_quaternion(env)
|
||||
|
||||
result = kcsolve.SolveResult()
|
||||
result.status = (
|
||||
kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
)
|
||||
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
|
||||
result.dof = -1 # skip DOF counting during drag for speed
|
||||
result.placements = _extract_placements(params, cache.system.bodies)
|
||||
|
||||
@@ -451,6 +477,7 @@ class _DragCache:
|
||||
"half_spaces", # list[HalfSpace]
|
||||
"weight_vec", # ndarray or None
|
||||
"post_step_fn", # Callable or None
|
||||
"pre_step_quats", # dict[str, tuple] — last-accepted quaternions per body
|
||||
)
|
||||
|
||||
|
||||
@@ -468,6 +495,46 @@ class _System:
|
||||
)
|
||||
|
||||
|
||||
def _enforce_quat_continuity(
|
||||
params: ParamTable,
|
||||
bodies: dict,
|
||||
pre_step_quats: dict,
|
||||
dragged_ids: set,
|
||||
) -> None:
|
||||
"""Ensure solved quaternions stay in the same hemisphere as pre-step.
|
||||
|
||||
For each non-grounded, non-dragged body, check whether the solved
|
||||
quaternion is in the opposite hemisphere from the pre-step quaternion
|
||||
(dot product < 0). If so, negate it — q and -q represent the same
|
||||
rotation, but staying in the same hemisphere prevents the C++ side
|
||||
from seeing a large-angle "flip".
|
||||
|
||||
This is the standard short-arc correction used in SLERP interpolation.
|
||||
"""
|
||||
for body in bodies.values():
|
||||
if body.grounded or body.part_id in dragged_ids:
|
||||
continue
|
||||
prev = pre_step_quats.get(body.part_id)
|
||||
if prev is None:
|
||||
continue
|
||||
|
||||
pfx = body.part_id + "/"
|
||||
qw = params.get_value(pfx + "qw")
|
||||
qx = params.get_value(pfx + "qx")
|
||||
qy = params.get_value(pfx + "qy")
|
||||
qz = params.get_value(pfx + "qz")
|
||||
|
||||
# Quaternion dot product: positive means same hemisphere
|
||||
dot = prev[0] * qw + prev[1] * qx + prev[2] * qy + prev[3] * qz
|
||||
|
||||
if dot < 0.0:
|
||||
# Negate to stay in the same hemisphere (identical rotation)
|
||||
params.set_value(pfx + "qw", -qw)
|
||||
params.set_value(pfx + "qx", -qx)
|
||||
params.set_value(pfx + "qy", -qy)
|
||||
params.set_value(pfx + "qz", -qz)
|
||||
|
||||
|
||||
def _build_system(ctx):
|
||||
"""Build the solver's internal representation from a SolveContext.
|
||||
|
||||
@@ -538,6 +605,19 @@ def _build_system(ctx):
|
||||
c.type,
|
||||
)
|
||||
continue
|
||||
# For PlanarConstraint, snapshot the world-frame normal at the
|
||||
# initial configuration so the distance residual uses a constant
|
||||
# reference direction. This prevents axial drift when a Planar
|
||||
# is combined with a Cylindrical on the same body pair.
|
||||
if isinstance(obj, PlanarConstraint):
|
||||
env = params.get_env()
|
||||
z_j = marker_z_axis(obj.body_j, obj.marker_j_quat)
|
||||
obj.reference_normal = (
|
||||
z_j[0].eval(env),
|
||||
z_j[1].eval(env),
|
||||
z_j[2].eval(env),
|
||||
)
|
||||
|
||||
constraint_objs.append(obj)
|
||||
constraint_indices.append(idx)
|
||||
all_residuals.extend(obj.residuals())
|
||||
@@ -581,9 +661,7 @@ def _run_diagnostics(residuals, params, residual_ranges, ctx, jac_exprs=None):
|
||||
if not hasattr(kcsolve, "ConstraintDiagnostic"):
|
||||
return diagnostics
|
||||
|
||||
diags = find_overconstrained(
|
||||
residuals, params, residual_ranges, jac_exprs=jac_exprs
|
||||
)
|
||||
diags = find_overconstrained(residuals, params, residual_ranges, jac_exprs=jac_exprs)
|
||||
for d in diags:
|
||||
cd = kcsolve.ConstraintDiagnostic()
|
||||
cd.constraint_id = ctx.constraints[d.constraint_index].id
|
||||
@@ -613,9 +691,7 @@ def _extract_placements(params, bodies):
|
||||
return placements
|
||||
|
||||
|
||||
def _monolithic_solve(
|
||||
all_residuals, params, quat_groups, post_step=None, weight_vector=None
|
||||
):
|
||||
def _monolithic_solve(all_residuals, params, quat_groups, post_step=None, weight_vector=None):
|
||||
"""Newton-Raphson solve with BFGS fallback on the full system.
|
||||
|
||||
Returns ``(converged, jac_exprs)`` so the caller can reuse the
|
||||
@@ -647,9 +723,7 @@ def _monolithic_solve(
|
||||
)
|
||||
nr_ms = (time.perf_counter() - t0) * 1000
|
||||
if not converged:
|
||||
log.info(
|
||||
"_monolithic_solve: Newton-Raphson failed (%.1f ms), trying BFGS", nr_ms
|
||||
)
|
||||
log.info("_monolithic_solve: Newton-Raphson failed (%.1f ms), trying BFGS", nr_ms)
|
||||
t1 = time.perf_counter()
|
||||
converged = bfgs_solve(
|
||||
all_residuals,
|
||||
|
||||
370
tests/test_drag.py
Normal file
370
tests/test_drag.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""Regression tests for interactive drag.
|
||||
|
||||
These tests exercise the drag protocol at the solver-internals level,
|
||||
verifying that constraints remain enforced across drag steps when the
|
||||
pre-pass has been applied to cached residuals.
|
||||
|
||||
Bug scenario: single_equation_pass runs during pre_drag, analytically
|
||||
solving variables from upstream constraints and baking their values as
|
||||
constants into downstream residual expressions. When a drag step
|
||||
changes those variables, the cached residuals use stale constants and
|
||||
downstream constraints (e.g. Planar distance=0) stop being enforced.
|
||||
|
||||
Fix: skip single_equation_pass in the drag path. Only substitution_pass
|
||||
(which replaces genuinely grounded parameters) is safe to cache.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from kindred_solver.constraints import (
|
||||
CoincidentConstraint,
|
||||
CylindricalConstraint,
|
||||
PlanarConstraint,
|
||||
RevoluteConstraint,
|
||||
)
|
||||
from kindred_solver.entities import RigidBody
|
||||
from kindred_solver.geometry import marker_z_axis
|
||||
from kindred_solver.newton import newton_solve
|
||||
from kindred_solver.params import ParamTable
|
||||
from kindred_solver.prepass import single_equation_pass, substitution_pass
|
||||
|
||||
ID_QUAT = (1, 0, 0, 0)
|
||||
|
||||
|
||||
def _build_residuals(bodies, constraint_objs):
|
||||
"""Build raw residual list + quat groups (no prepass)."""
|
||||
all_residuals = []
|
||||
for c in constraint_objs:
|
||||
all_residuals.extend(c.residuals())
|
||||
|
||||
quat_groups = []
|
||||
for body in bodies:
|
||||
if not body.grounded:
|
||||
all_residuals.append(body.quat_norm_residual())
|
||||
quat_groups.append(body.quat_param_names())
|
||||
|
||||
return all_residuals, quat_groups
|
||||
|
||||
|
||||
def _eval_raw_residuals(bodies, constraint_objs, params):
|
||||
"""Evaluate original constraint residuals at current param values.
|
||||
|
||||
Returns the max absolute residual value — the ground truth for
|
||||
whether constraints are satisfied regardless of prepass state.
|
||||
"""
|
||||
raw, _ = _build_residuals(bodies, constraint_objs)
|
||||
env = params.get_env()
|
||||
return max(abs(r.eval(env)) for r in raw)
|
||||
|
||||
|
||||
class TestPrepassDragRegression:
|
||||
"""single_equation_pass bakes stale values that break drag.
|
||||
|
||||
Setup: ground --Revolute--> arm --Planar(d=0)--> plate
|
||||
|
||||
The Revolute pins arm's origin to ground (fixes arm/tx, arm/ty,
|
||||
arm/tz via single_equation_pass). The Planar keeps plate coplanar
|
||||
with arm. After prepass, the Planar residual has arm's position
|
||||
baked as Const(0.0).
|
||||
|
||||
During drag: arm/tz is set to 5.0. Because arm/tz is marked fixed
|
||||
by prepass, Newton can't correct it, AND the Planar residual still
|
||||
uses Const(0.0) instead of the live value 5.0. The Revolute
|
||||
constraint (arm at origin) is silently violated.
|
||||
"""
|
||||
|
||||
def _setup(self):
|
||||
pt = ParamTable()
|
||||
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
|
||||
arm = RigidBody("arm", pt, (10, 0, 0), ID_QUAT)
|
||||
plate = RigidBody("plate", pt, (10, 5, 0), ID_QUAT)
|
||||
|
||||
constraints = [
|
||||
RevoluteConstraint(ground, (0, 0, 0), ID_QUAT, arm, (0, 0, 0), ID_QUAT),
|
||||
PlanarConstraint(arm, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
|
||||
]
|
||||
bodies = [ground, arm, plate]
|
||||
return pt, bodies, constraints
|
||||
|
||||
def test_bug_stale_constants_after_single_equation_pass(self):
|
||||
"""Document the bug: prepass bakes arm/tz=0, drag breaks constraints."""
|
||||
pt, bodies, constraints = self._setup()
|
||||
raw_residuals, quat_groups = _build_residuals(bodies, constraints)
|
||||
|
||||
# Simulate OLD pre_drag: substitution + single_equation_pass
|
||||
residuals = substitution_pass(raw_residuals, pt)
|
||||
residuals = single_equation_pass(residuals, pt)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
# Verify prepass fixed arm's position params
|
||||
assert pt.is_fixed("arm/tx")
|
||||
assert pt.is_fixed("arm/ty")
|
||||
assert pt.is_fixed("arm/tz")
|
||||
|
||||
# Simulate drag: move arm up (set_value, as drag_step does)
|
||||
pt.set_value("arm/tz", 5.0)
|
||||
pt.set_value("plate/tz", 5.0) # initial guess near drag
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
|
||||
# Solver "converges" on the stale cached residuals
|
||||
assert ok
|
||||
|
||||
# But the TRUE constraints are violated: arm should be at z=0
|
||||
# (Revolute pins it to ground) yet it's at z=5
|
||||
max_err = _eval_raw_residuals(bodies, constraints, pt)
|
||||
assert max_err > 1.0, (
|
||||
f"Expected large raw residual violation, got {max_err:.6e}. "
|
||||
"The bug should cause the Revolute z-residual to be ~5.0"
|
||||
)
|
||||
|
||||
def test_fix_no_single_equation_pass_for_drag(self):
|
||||
"""With the fix: skip single_equation_pass, constraints hold."""
|
||||
pt, bodies, constraints = self._setup()
|
||||
raw_residuals, quat_groups = _build_residuals(bodies, constraints)
|
||||
|
||||
# Simulate FIXED pre_drag: substitution only
|
||||
residuals = substitution_pass(raw_residuals, pt)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
# arm/tz should NOT be fixed
|
||||
assert not pt.is_fixed("arm/tz")
|
||||
|
||||
# Simulate drag: move arm up
|
||||
pt.set_value("arm/tz", 5.0)
|
||||
pt.set_value("plate/tz", 5.0)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
# Newton pulls arm back to z=0 (Revolute enforced) and plate follows
|
||||
max_err = _eval_raw_residuals(bodies, constraints, pt)
|
||||
assert max_err < 1e-8, f"Raw residual violation {max_err:.6e} — constraints not satisfied"
|
||||
|
||||
|
||||
class TestCoincidentPlanarDragRegression:
|
||||
"""Coincident upstream + Planar downstream — same bug class.
|
||||
|
||||
ground --Coincident--> bracket --Planar(d=0)--> plate
|
||||
|
||||
Coincident fixes bracket/tx,ty,tz. After prepass, the Planar
|
||||
residual has bracket's position baked. Drag moves bracket;
|
||||
the Planar uses stale constants.
|
||||
"""
|
||||
|
||||
def _setup(self):
|
||||
pt = ParamTable()
|
||||
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
|
||||
bracket = RigidBody("bracket", pt, (0, 0, 0), ID_QUAT)
|
||||
plate = RigidBody("plate", pt, (10, 5, 0), ID_QUAT)
|
||||
|
||||
constraints = [
|
||||
CoincidentConstraint(ground, (0, 0, 0), bracket, (0, 0, 0)),
|
||||
PlanarConstraint(bracket, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
|
||||
]
|
||||
bodies = [ground, bracket, plate]
|
||||
return pt, bodies, constraints
|
||||
|
||||
def test_bug_coincident_planar(self):
|
||||
"""Prepass fixes bracket/tz, Planar uses stale constant during drag."""
|
||||
pt, bodies, constraints = self._setup()
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
residuals = substitution_pass(raw, pt)
|
||||
residuals = single_equation_pass(residuals, pt)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
assert pt.is_fixed("bracket/tz")
|
||||
|
||||
# Drag bracket up
|
||||
pt.set_value("bracket/tz", 5.0)
|
||||
pt.set_value("plate/tz", 5.0)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
# True constraints violated
|
||||
max_err = _eval_raw_residuals(bodies, constraints, pt)
|
||||
assert max_err > 1.0, f"Expected raw violation from stale prepass, got {max_err:.6e}"
|
||||
|
||||
def test_fix_coincident_planar(self):
|
||||
"""With the fix: constraints satisfied after drag."""
|
||||
pt, bodies, constraints = self._setup()
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
residuals = substitution_pass(raw, pt)
|
||||
# No single_equation_pass
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
assert not pt.is_fixed("bracket/tz")
|
||||
|
||||
# Drag bracket up
|
||||
pt.set_value("bracket/tz", 5.0)
|
||||
pt.set_value("plate/tz", 5.0)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
max_err = _eval_raw_residuals(bodies, constraints, pt)
|
||||
assert max_err < 1e-8, f"Raw residual violation {max_err:.6e} — constraints not satisfied"
|
||||
|
||||
|
||||
class TestDragDoesNotBreakStaticSolve:
|
||||
"""Verify that the static solve path (with single_equation_pass) still works.
|
||||
|
||||
The fix only affects pre_drag — the static solve() path continues to
|
||||
use single_equation_pass for faster convergence.
|
||||
"""
|
||||
|
||||
def test_static_solve_still_uses_prepass(self):
|
||||
"""Static solve with single_equation_pass converges correctly."""
|
||||
pt = ParamTable()
|
||||
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
|
||||
arm = RigidBody("arm", pt, (10, 0, 0), ID_QUAT)
|
||||
plate = RigidBody("plate", pt, (10, 5, 8), ID_QUAT)
|
||||
|
||||
constraints = [
|
||||
RevoluteConstraint(ground, (0, 0, 0), ID_QUAT, arm, (0, 0, 0), ID_QUAT),
|
||||
PlanarConstraint(arm, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
|
||||
]
|
||||
bodies = [ground, arm, plate]
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
# Full prepass (static solve path)
|
||||
residuals = substitution_pass(raw, pt)
|
||||
residuals = single_equation_pass(residuals, pt)
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
# All raw constraints satisfied
|
||||
max_err = _eval_raw_residuals(bodies, constraints, pt)
|
||||
assert max_err < 1e-8
|
||||
|
||||
# arm at origin (Revolute), plate coplanar (z=0)
|
||||
env = pt.get_env()
|
||||
assert abs(env["arm/tx"]) < 1e-8
|
||||
assert abs(env["arm/ty"]) < 1e-8
|
||||
assert abs(env["arm/tz"]) < 1e-8
|
||||
assert abs(env["plate/tz"]) < 1e-8
|
||||
|
||||
|
||||
def _set_reference_normal(planar_obj, params):
|
||||
"""Snapshot world-frame normal as reference (mirrors _build_system logic)."""
|
||||
env = params.get_env()
|
||||
z_j = marker_z_axis(planar_obj.body_j, planar_obj.marker_j_quat)
|
||||
planar_obj.reference_normal = (
|
||||
z_j[0].eval(env),
|
||||
z_j[1].eval(env),
|
||||
z_j[2].eval(env),
|
||||
)
|
||||
|
||||
|
||||
class TestPlanarCylindricalAxialDrift:
|
||||
"""Planar + Cylindrical on the same body pair: axial drift regression.
|
||||
|
||||
Bug: PlanarConstraint's distance residual uses a body-attached normal
|
||||
(z_j) that rotates with the body. When combined with Cylindrical
|
||||
(which allows rotation about the axis), the normal can tilt during
|
||||
Newton iteration, allowing the body to drift along the cylinder axis
|
||||
while technically satisfying the rotated distance residual.
|
||||
|
||||
Fix: snapshot the world-frame normal at system-build time and use
|
||||
Const nodes in the distance residual (reference_normal).
|
||||
"""
|
||||
|
||||
def _setup(self, offset=0.0):
|
||||
"""Cylindrical + Planar along Z between ground and a free body.
|
||||
|
||||
Free body starts displaced along Z so the solver must move it.
|
||||
"""
|
||||
pt = ParamTable()
|
||||
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
|
||||
free = RigidBody("free", pt, (0, 0, 5), ID_QUAT)
|
||||
|
||||
cyl = CylindricalConstraint(
|
||||
ground,
|
||||
(0, 0, 0),
|
||||
ID_QUAT,
|
||||
free,
|
||||
(0, 0, 0),
|
||||
ID_QUAT,
|
||||
)
|
||||
planar = PlanarConstraint(
|
||||
ground,
|
||||
(0, 0, 0),
|
||||
ID_QUAT,
|
||||
free,
|
||||
(0, 0, 0),
|
||||
ID_QUAT,
|
||||
offset=offset,
|
||||
)
|
||||
bodies = [ground, free]
|
||||
constraints = [cyl, planar]
|
||||
return pt, bodies, constraints, planar
|
||||
|
||||
def test_reference_normal_prevents_axial_drift(self):
|
||||
"""With reference_normal, free body converges to z=0 (distance=0)."""
|
||||
pt, bodies, constraints, planar = self._setup(offset=0.0)
|
||||
_set_reference_normal(planar, pt)
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
residuals = substitution_pass(raw, pt)
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
env = pt.get_env()
|
||||
assert abs(env["free/tz"]) < 1e-8, (
|
||||
f"free/tz = {env['free/tz']:.6e}, expected ~0.0 (axial drift)"
|
||||
)
|
||||
|
||||
def test_reference_normal_with_offset(self):
|
||||
"""With reference_normal and offset=3.0, free body converges to z=-3.
|
||||
|
||||
Sign convention: point_plane_distance = (p_ground - p_free) · n,
|
||||
so offset=3 means -z_free - 3 = 0, i.e. z_free = -3.
|
||||
"""
|
||||
pt, bodies, constraints, planar = self._setup(offset=3.0)
|
||||
_set_reference_normal(planar, pt)
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
residuals = substitution_pass(raw, pt)
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
env = pt.get_env()
|
||||
assert abs(env["free/tz"] + 3.0) < 1e-8, f"free/tz = {env['free/tz']:.6e}, expected ~-3.0"
|
||||
|
||||
def test_drag_step_no_drift(self):
|
||||
"""After drag perturbation, re-solve keeps axial position locked."""
|
||||
pt, bodies, constraints, planar = self._setup(offset=0.0)
|
||||
_set_reference_normal(planar, pt)
|
||||
raw, qg = _build_residuals(bodies, constraints)
|
||||
|
||||
residuals = substitution_pass(raw, pt)
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
env = pt.get_env()
|
||||
assert abs(env["free/tz"]) < 1e-8
|
||||
|
||||
# Simulate drag: perturb the free body's position
|
||||
pt.set_value("free/tx", 3.0)
|
||||
pt.set_value("free/ty", 2.0)
|
||||
pt.set_value("free/tz", 1.0) # axial perturbation
|
||||
|
||||
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
|
||||
assert ok
|
||||
|
||||
env = pt.get_env()
|
||||
# Cylindrical allows x/y freedom only on the axis line,
|
||||
# but Planar distance=0 must hold: z stays at 0
|
||||
assert abs(env["free/tz"]) < 1e-8, (
|
||||
f"free/tz = {env['free/tz']:.6e}, expected ~0.0 after drag re-solve"
|
||||
)
|
||||
Reference in New Issue
Block a user