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!