diff --git a/python/xy/_native.py b/python/xy/_native.py index ca9cfd7..6296481 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 35 +ABI_VERSION = 36 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. @@ -166,6 +166,25 @@ def _load() -> ctypes.CDLL: ctypes.c_size_t, ctypes.c_void_p, ] + lib.xy_svg_poly_path.restype = ctypes.c_size_t + lib.xy_svg_poly_path.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_size_t, + ] + lib.xy_m4_points.restype = ctypes.c_size_t + lib.xy_m4_points.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_double, + ctypes.c_double, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_void_p, + ] lib.xy_stacked_bounds.restype = ctypes.c_int32 lib.xy_stacked_bounds.argtypes = [ ctypes.c_void_p, @@ -1779,6 +1798,60 @@ def m4_indices( return out[:written].copy() +def m4_points( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + x0: float, + x1: float, + n_buckets: int, +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + """M4-decimate an x/y pair without materializing gather indices in Python.""" + n_buckets = _bounded_positive_int(n_buckets, "n_buckets") + x0, x1 = _finite_increasing(x0, x1, "x range") + x = _as_f64(x, "x") + y = _as_f64(y, "y") + if len(x) != len(y): + raise ValueError("x and y must have equal length") + if len(x) == 0: + empty = np.empty(0, dtype=np.float64) + return empty, empty.copy() + out_x = np.empty(n_buckets * 4, dtype=np.float64) + out_y = np.empty(n_buckets * 4, dtype=np.float64) + written = _lib.xy_m4_points( + _ptr_f64(x), + _ptr_f64(y), + len(x), + x0, + x1, + n_buckets, + _ptr_f64(out_x), + _ptr_f64(out_y), + ) + if written == _USIZE_MAX: + raise ValueError("invalid m4 arguments") + return out_x[:written], out_y[:written] + + +def svg_poly_path(x: npt.ArrayLike, y: npt.ArrayLike) -> str: + """Serialize parallel screen coordinates as SVG path data in Rust.""" + xa = np.ascontiguousarray(x, dtype=np.float64).reshape(-1) + ya = np.ascontiguousarray(y, dtype=np.float64).reshape(-1) + if len(xa) != len(ya) or len(xa) == 0: + raise ValueError("x and y must be non-empty and have equal length") + # Normal chart coordinates fit comfortably. The ABI returns the exact + # requirement without writing when an adversarial fixed-point value needs + # more room, so the uncommon retry remains allocation-safe. + capacity = max(64, len(xa) * 32) + while True: + out = ctypes.create_string_buffer(capacity) + written = _lib.xy_svg_poly_path(_ptr_f64(xa), _ptr_f64(ya), len(xa), out, capacity) + if written == _USIZE_MAX: + raise ValueError("invalid SVG polyline coordinates") + if written <= capacity: + return out.raw[:written].decode("ascii") + capacity = written + + def marching_squares( z: npt.NDArray[np.float64], x_coords: npt.NDArray[np.float64], diff --git a/python/xy/_payload.py b/python/xy/_payload.py index c48111f..739c24b 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -10,7 +10,7 @@ import numpy as np -from . import channels, kernels, lod +from . import _native, channels, kernels, lod from ._trace import Trace from .columns import Column from .config import ( @@ -347,6 +347,11 @@ def _m4_decimate( Returns `(tier, arrays)` — shared by the line and area emitters.""" if t.n_points <= DECIMATION_THRESHOLD: return "direct", arrays + if len(arrays) == 2: + selected = _native.m4_points( + arrays[0], arrays[1], xr[0], xr[1] + np.finfo(np.float64).eps, px_width + ) + return "decimated", selected idx = kernels.m4_indices( arrays[0], arrays[1], xr[0], xr[1] + np.finfo(np.float64).eps, px_width ) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 13ddd5e..0e28fb4 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -28,7 +28,7 @@ import numpy as np -from . import _png +from . import _native, _png from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -836,9 +836,7 @@ def _rounded_rect_path( def _poly_path(px: np.ndarray, py: np.ndarray) -> str: - parts = [f"M {_num(px[0])} {_num(py[0])}"] - parts.extend(f"L {_num(px[i])} {_num(py[i])}" for i in range(1, len(px))) - return " ".join(parts) + return _native.svg_poly_path(px, py) def _curve_path(xv: np.ndarray, yv: np.ndarray, sx: _Scale, sy: _Scale, smooth: bool) -> str: diff --git a/src/lib.rs b/src/lib.rs index 8026f5c..dc8cae6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ mod font; pub mod kernels; pub mod raster; mod simd; +pub mod svg; pub mod tiles; use kernels::ZoneMap; @@ -79,7 +80,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 35; +pub const ABI_VERSION: u32 = 36; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] @@ -87,6 +88,40 @@ pub extern "C" fn xy_abi_version() -> u32 { ABI_VERSION } +/// Serialize parallel f64 screen coordinates into SVG polyline path data. +/// Returns the required byte count, or `usize::MAX` for invalid inputs. When +/// `out_cap` is too small no bytes are written, allowing callers to retry. +/// +/// # Safety +/// `x` and `y` must point to `len` readable f64s. When `out_cap` is sufficient, +/// `out` must point to `out_cap` writable bytes. +#[no_mangle] +pub unsafe extern "C" fn xy_svg_poly_path( + x: *const f64, + y: *const f64, + len: usize, + out: *mut u8, + out_cap: usize, +) -> usize { + if len == 0 || x.is_null() || y.is_null() { + return usize::MAX; + } + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + let Some(path) = ffi_guard(None, || svg::poly_path(x, y)) else { + return usize::MAX; + }; + let required = path.len(); + if out_cap < required { + return required; + } + if out.is_null() { + return usize::MAX; + } + std::slice::from_raw_parts_mut(out, out_cap)[..required].copy_from_slice(path.as_bytes()); + required +} + /// Factor `len` fixed-width records into first-seen u32 codes. Returns the /// number of unique records or `usize::MAX` on invalid pointers/dimensions. /// @@ -555,6 +590,56 @@ pub unsafe extern "C" fn xy_m4_indices( idx.len() } +/// Fused M4 decimation for a parallel x/y pair. Unlike `xy_m4_indices`, this +/// writes the selected values directly and avoids returning an index array for +/// Python to gather through NumPy. SVG and PNG payload construction both use +/// this entry point, so their common reduction path stays native. +/// +/// Returns the number of values written, or `usize::MAX` on invalid input. +/// `out_x` and `out_y` must each hold `4 * n_buckets` f64 values. +/// +/// # Safety +/// `x` and `y` must point to `len` readable f64s; both output pointers must +/// address the documented writable capacity. +#[no_mangle] +pub unsafe extern "C" fn xy_m4_points( + x: *const f64, + y: *const f64, + len: usize, + x0: f64, + x1: f64, + n_buckets: usize, + out_x: *mut f64, + out_y: *mut f64, +) -> usize { + if n_buckets == 0 || !finite_gt(x0, x1) || len > u32::MAX as usize { + return usize::MAX; + } + let out_len = match n_buckets.checked_mul(4) { + Some(n) => n, + None => return usize::MAX, + }; + if len == 0 { + return 0; + } + if x.is_null() || y.is_null() || out_x.is_null() || out_y.is_null() { + return usize::MAX; + } + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + let Some(idx) = ffi_guard(None, || Some(kernels::m4_indices(x, y, x0, x1, n_buckets))) else { + return usize::MAX; + }; + let out_x = std::slice::from_raw_parts_mut(out_x, out_len); + let out_y = std::slice::from_raw_parts_mut(out_y, out_len); + for (dst, &source) in idx.iter().enumerate() { + let source = source as usize; + out_x[dst] = x[source]; + out_y[dst] = y[source]; + } + idx.len() +} + /// Native stacked-series layout. `values`, `out_lower`, and `out_upper` are /// row-major `rows * cols` f64 buffers. `baseline` uses the generic engine /// layout ids documented by `kernels::stacked_bounds_into`. diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..30cddb4 --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,58 @@ +//! Focused native SVG serialization helpers. +//! +//! SVG and PNG share screen-space geometry. Keeping the high-cardinality +//! coordinate serialization here avoids creating one Python string per point; +//! broader scene construction can migrate behind the same private boundary. + +use std::fmt::Write; + +#[inline] +fn push_num(out: &mut String, value: f64) { + let start = out.len(); + write!(out, "{value:.2}").expect("writing to String cannot fail"); + while out.as_bytes().last() == Some(&b'0') { + out.pop(); + } + if out.as_bytes().last() == Some(&b'.') { + out.pop(); + } + // Match Python's fixed-point formatter for negative values rounding to 0. + if &out[start..] == "-0" { + out.truncate(start); + out.push_str("-0"); + } +} + +/// Serialize parallel screen-space coordinates as an SVG path data string. +pub fn poly_path(x: &[f64], y: &[f64]) -> Option { + if x.len() != y.len() || x.is_empty() || x.iter().chain(y).any(|v| !v.is_finite()) { + return None; + } + let mut out = String::with_capacity(x.len().saturating_mul(24)); + for (i, (&x, &y)) in x.iter().zip(y).enumerate() { + if i != 0 { + out.push(' '); + } + out.push(if i == 0 { 'M' } else { 'L' }); + out.push(' '); + push_num(&mut out, x); + out.push(' '); + push_num(&mut out, y); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poly_path_matches_public_svg_number_format() { + assert_eq!( + poly_path(&[1.0, 2.345, -0.001], &[4.5, 6.789, -0.0]).as_deref(), + Some("M 1 4.5 L 2.35 6.79 L -0 -0") + ); + assert!(poly_path(&[], &[]).is_none()); + assert!(poly_path(&[1.0], &[f64::NAN]).is_none()); + } +} diff --git a/tests/test_kernels.py b/tests/test_kernels.py index 9289b85..83200e7 100644 --- a/tests/test_kernels.py +++ b/tests/test_kernels.py @@ -1190,3 +1190,26 @@ def test_density_rgba_validates_shape_and_domain(): k.density_rgba(np.zeros(4, dtype=np.uint8), 2, 2, -1.0, stops, 1.0) with pytest.raises(ValueError, match="opacity"): k.density_rgba(np.zeros(4, dtype=np.uint8), 2, 2, 1.0, stops, 1.1) + + +def test_native_svg_poly_path_format_and_validation(): + from xy import _native + + assert _native.svg_poly_path([1.0, 2.345, -0.001], [4.5, 6.789, -0.0]) == ( + "M 1 4.5 L 2.35 6.79 L -0 -0" + ) + with pytest.raises(ValueError, match="non-empty"): + _native.svg_poly_path([], []) + with pytest.raises(ValueError, match="coordinates"): + _native.svg_poly_path([1.0], [np.nan]) + + +def test_native_m4_points_matches_index_gather(): + from xy import _native + + x = np.arange(10_000, dtype=np.float64) + y = np.sin(x * 0.01) + idx = _native.m4_indices(x, y, 500.0, 9_500.0, 128) + selected_x, selected_y = _native.m4_points(x, y, 500.0, 9_500.0, 128) + np.testing.assert_array_equal(selected_x, x[idx]) + np.testing.assert_array_equal(selected_y, y[idx])