diff --git a/src/math/transform.cpp b/src/math/transform.cpp index e98bff1..43fb93e 100644 --- a/src/math/transform.cpp +++ b/src/math/transform.cpp @@ -15,6 +15,26 @@ void axis_ends(Range r, bool log, f64& lo, f64& hi) noexcept { } } +/// Scale mapping `[lo,hi]` (transform space, already widened by `axis_ends`) +/// onto the pixel interval `[p0,p1]`. +/// +/// A collapsed pixel span (`|p1 - p0| <= kMinPixelSpan`, e.g. a zero-height +/// panel or an untouched layout) would yield a zero scale, so `to_data_*` would +/// divide by zero and return NaN or +/-inf. Instead the span is substituted by +/// the unit span `p0 -> p0 + 1`: the scale is then finite and non-zero and the +/// mapping stays invertible (`to_data_x(to_screen_x(v)) == v`), the axis simply +/// occupying one pixel rather than none. Non-collapsed spans are used verbatim, +/// so ordinary ranges are unaffected. +f64 axis_scale(f64 lo, f64 hi, f64 p0, f64 p1) noexcept { + /// Pixel coordinates in this library are O(1e3), so 1e-12 is + /// indistinguishable from zero while staying far below any span a real + /// viewport can produce. + constexpr f64 kMinPixelSpan = 1e-12; + const f64 span = p1 - p0; + const f64 effective_span = std::fabs(span) <= kMinPixelSpan ? 1.0 : span; + return effective_span / (hi - lo); +} + } // namespace f64 Transform::log10_positive(f64 v) noexcept { @@ -30,12 +50,12 @@ Transform Transform::from_range(Range x, Range y, f64 px0, f64 px1, f64 py0, f64 f64 x_lo = 0.0, x_hi = 0.0; axis_ends(x, lx, x_lo, x_hi); - t.sx = (px1 - px0) / (x_hi - x_lo); + t.sx = axis_scale(x_lo, x_hi, px0, px1); t.tx = px0 - x_lo * t.sx; f64 y_lo = 0.0, y_hi = 0.0; axis_ends(y, ly, y_lo, y_hi); - t.sy = (py1 - py0) / (y_hi - y_lo); + t.sy = axis_scale(y_lo, y_hi, py0, py1); t.ty = py0 - y_lo * t.sy; return t; diff --git a/tests/test_transform.cpp b/tests/test_transform.cpp index 1180a34..2b960dd 100644 --- a/tests/test_transform.cpp +++ b/tests/test_transform.cpp @@ -29,3 +29,65 @@ PLOT_TEST(zoom_anchor_is_invariant) { t.tx = before - anchor_data * t.sx; PLOT_CHECK_NEAR(t.to_screen_x(anchor_data), before, 1e-12); } +PLOT_TEST(from_range_linear) { + const Transform t = + Transform::from_range({0.0, 10.0}, {-1.0, 1.0}, 0.0, 100.0, 0.0, 200.0, false, false); + PLOT_CHECK_NEAR(t.to_screen_x(0.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_x(10.0), 100.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_y(-1.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_y(1.0), 200.0, 1e-12); + PLOT_CHECK_NEAR(t.to_data_x(t.to_screen_x(3.5)), 3.5, 1e-12); + PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(0.25)), 0.25, 1e-12); +} + +PLOT_TEST(from_range_flipped_pixel_axis) { + // px0 > px1 reverses the direction of the axis; the round trip must survive. + const Transform t = + Transform::from_range({0.0, 10.0}, {0.0, 10.0}, 100.0, 0.0, 200.0, 0.0, false, false); + PLOT_CHECK_NEAR(t.to_screen_x(0.0), 100.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_x(10.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_y(0.0), 200.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_y(10.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_data_x(t.to_screen_x(2.5)), 2.5, 1e-12); + PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.5)), 7.5, 1e-12); +} + +PLOT_TEST(from_range_log_x) { + const Transform t = + Transform::from_range({1.0, 1000.0}, {0.0, 1.0}, 0.0, 300.0, 0.0, 100.0, true, false); + PLOT_CHECK(t.log_x); + PLOT_CHECK(!t.log_y); + PLOT_CHECK_NEAR(t.to_screen_x(1.0), 0.0, 1e-12); + PLOT_CHECK_NEAR(t.to_screen_x(1000.0), 300.0, 1e-12); + // log10(31.6227766) == 1.5 lands exactly halfway along the axis. + PLOT_CHECK_NEAR(t.to_screen_x(31.6227766), 150.0, 1e-6); + PLOT_CHECK_NEAR(t.to_data_x(t.to_screen_x(31.6227766)), 31.6227766, 1e-9); +} + +PLOT_TEST(from_range_degenerate_data_range) { + // min == max: the range is widened, so the mapping stays finite and invertible. + const Transform t = + Transform::from_range({5.0, 5.0}, {2.0, 2.0}, 0.0, 100.0, 0.0, 200.0, false, false); + PLOT_CHECK(std::isfinite(t.sx) && t.sx != 0.0); + PLOT_CHECK(std::isfinite(t.sy) && t.sy != 0.0); + PLOT_CHECK_NEAR(t.to_data_x(t.to_screen_x(5.0)), 5.0, 1e-9); + PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(2.0)), 2.0, 1e-9); +} + +PLOT_TEST(from_range_collapsed_pixel_span) { + // px0 == px1 previously gave sx == 0, so to_data_x returned NaN/inf. + const Transform t = + Transform::from_range({0.0, 10.0}, {0.0, 10.0}, 50.0, 50.0, 25.0, 25.0, false, false); + PLOT_CHECK(std::isfinite(t.sx) && t.sx != 0.0); + PLOT_CHECK(std::isfinite(t.sy) && t.sy != 0.0); + PLOT_CHECK_NEAR(t.to_data_x(t.to_screen_x(4.0)), 4.0, 1e-9); + PLOT_CHECK_NEAR(t.to_data_y(t.to_screen_y(7.0)), 7.0, 1e-9); + PLOT_CHECK(std::isfinite(t.to_data_x(t.to_screen_x(0.0)))); + PLOT_CHECK(std::isfinite(t.to_data_y(t.to_screen_y(10.0)))); +} + +PLOT_TEST(log10_positive_clamps_non_positive) { + PLOT_CHECK(std::isfinite(Transform::log10_positive(0.0))); + PLOT_CHECK(std::isfinite(Transform::log10_positive(-5.0))); + PLOT_CHECK_NEAR(Transform::log10_positive(1.0), 0.0, 1e-12); +}