BUG: vtkAxis::RecalculateTickSpacing() loops forever when the axis range is only a few ULPs wide

Hi all,

We hit a GUI freeze (one thread at 100% CPU, never recovers) in an application that uses vtkChartXY. It comes from an infinite loop in vtkAxis::RecalculateTickSpacing(). We saw it on VTK 9.6 (Slicer’s fork), and the same code is on current master.

Minimal reproducer

import math, vtk

a = vtk.vtkAxis()
a.SetPosition(vtk.vtkAxis.BOTTOM)
a.SetPoint1(0, 0)
a.SetPoint2(400, 0)
a.SetRange(1e6, math.nextafter(1e6, math.inf))   # range = 1 ULP
a.RecalculateTickSpacing()                        # never returns
print("returned")

Root cause

In Charts/Core/vtkAxis.cxx, RecalculateTickSpacing() snaps min/max onto the tick grid by stepping:

if (this->Minimum < this->Maximum)
{
  while (min < this->Minimum)
  {
    min += this->TickInterval;
  }
  while (max > this->Maximum)
  {
    max -= this->TickInterval;
  }
}
else
{
  // same, mirrored
}

TickInterval comes from NiceMinMax() and is roughly range / maxTicks. When the range is a few ULPs, TickInterval is smaller than half an ULP of min, so min + TickInterval == min in double precision. The loop condition never changes and the loop never ends.

The existing guard only handles TickInterval == 0.0. A NaN TickInterval gets through too; the loops happen to exit, but NaN then reaches GenerateTickLabels().

Proposed fix

Compute the snapped bounds in closed form instead of stepping, and reject any non-positive or non-finite interval:

      // Calculated tickinterval may be 0 (or not finite). So calculation of new
      // minimum and maximum by incrementing/decrementing using tickinterval will fail.
      if (!(this->TickInterval > 0.0) || !std::isfinite(this->TickInterval))
      {
        return;
      }
      // Snap min/max onto the first/last tick inside the axis range. This is
      // computed in closed form rather than by repeatedly adding TickInterval:
      // when the range is only a few ULPs wide (e.g. after repeated zooming),
      // TickInterval can be below the floating-point resolution of min/max and
      // the addition no longer changes the value, which would loop forever.
      const double ti = this->TickInterval;
      if (this->Minimum < this->Maximum)
      {
        if (min < this->Minimum)
        {
          min = std::max(min + std::ceil((this->Minimum - min) / ti) * ti, this->Minimum);
        }
        if (max > this->Maximum)
        {
          max = std::min(max - std::ceil((max - this->Maximum) / ti) * ti, this->Maximum);
        }
      }
      else
      {
        if (min > this->Minimum)
        {
          min = std::min(min - std::ceil((min - this->Minimum) / ti) * ti, this->Minimum);
        }
        if (max < this->Maximum)
        {
          max = std::max(max + std::ceil((this->Maximum - max) / ti) * ti, this->Maximum);
        }
      }
      this->GenerateTickLabels(min, max);

The result keeps the same meaning as the loops: the first tick >= Minimum and the last tick <= Maximum, mirrored for reversed axes. The std::max/std::min clamp preserves that guarantee even in the degenerate case where the step is below floating-point resolution. <algorithm> and <cmath> are already included.

I’m happy to open a merge request with this change. Is this the right approach, or would you prefer the fix in vtkChartXY::ZoomInAxes (e.g. refusing to zoom below a minimum relative range)? We think the fix belongs in vtkAxis, because anything that sets a tiny range through SetRange() can trigger the same hang.

Thanks!

Hi @riep

A merge request with the proposed changes would be appreciated.

Best,