Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion python/xy/_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
7 changes: 6 additions & 1 deletion python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
Expand Down
6 changes: 2 additions & 4 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
87 changes: 86 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod font;
pub mod kernels;
pub mod raster;
mod simd;
pub mod svg;
pub mod tiles;

use kernels::ZoneMap;
Expand Down Expand Up @@ -79,14 +80,48 @@ 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]
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.
///
Expand Down Expand Up @@ -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`.
Expand Down
58 changes: 58 additions & 0 deletions src/svg.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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());
}
}
23 changes: 23 additions & 0 deletions tests/test_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Loading