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
174 changes: 155 additions & 19 deletions crates/xyg-engine/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10972,7 +10972,7 @@ mod tests {
}

#[test]
fn scene_v7_polyfill_closes_triangle_path() {
fn scene_v7_polyfill_triangle_runs_drive_every_consumer_and_clip() {
let layout = PlotLayout::new(200.0, 120.0, 20.0, 10.0, 20.0, 20.0).unwrap();
let x = AxisScale::new(
ScaleKind::Linear,
Expand All @@ -10994,39 +10994,175 @@ mod tests {
false,
)
.unwrap();
let batch = SceneBatch::new(
let legend = SceneLegend {
location: LegendLocation::UpperRight,
title: String::new(),
font_size: 11.0,
title_font_size: 12.0,
text_rgba: [32, 32, 32, 255],
frame_fill_rgba: [255, 255, 255, 230],
frame_stroke_rgba: [32, 32, 32, 71],
entries: vec![SceneLegendEntry {
style_ref: 0,
kind: SceneRecordKind::PolyFill,
symbol: 0,
fill_rgba: [34, 197, 94, 191],
stroke_rgba: [0, 0, 0, 0],
label: "literal mesh".into(),
}],
};
let batch = SceneBatch::new_with_decorations(
layout,
1,
2,
x,
y,
&[4, 4, 4],
&[9, 9, 9],
&[0, 0, 0],
&[34, 197, 94, 255],
SceneChromeStyle::default(),
SceneChromeText::default(),
Some(legend),
&[4, 4, 4, 4, 4, 4],
&[9, 9, 9, 10, 10, 10],
&[0, 0, 0, 0, 0, 0],
&[34, 197, 94, 191],
&[0, 0, 0, 0],
&[0.0],
&[0.0, 0.0, 0.0],
&[0, 0, 0],
&[0.0, 1.0, 0.5],
&[0.0, 0.0, 1.0],
&[0.0, 0.0, 0.0],
&[0.0, 0.0, 0.0],
&[0.0; 6],
&[0; 6],
&[-0.25, 0.75, 0.25, 1.0, 2.25, 1.5],
&[0.25, 0.25, 1.25, 0.5, 0.5, 1.75],
&[0.0; 6],
&[0.0; 6],
)
.unwrap();
let encoded = batch.encode();
assert_eq!(
u32::from_le_bytes(encoded[4..8].try_into().unwrap()),
SCENE_VERSION
);
let svg = SceneDocument::decode(&encoded).unwrap().to_svg();
assert!(svg.contains("<path d=\"M "));
assert!(svg.contains(" Z\""));
let commands = SceneDocument::decode(&encoded)
let document = SceneDocument::decode(&encoded).unwrap();
let svg = document.to_svg();
assert_eq!(svg.matches("<path d=\"M ").count(), 2);
assert_eq!(svg.matches(" Z\"").count(), 2);
assert!(svg.contains("<g clip-path=\"url(#xy-scene-plot)\">"));
assert!(svg.contains("role=\"listitem\"") && svg.contains("literal mesh"));

let commands = document.to_raster_commands(1.0).unwrap();
assert_eq!(commands[82], 0); // canonical plot clip follows two backgrounds
let grid_count = linear_ticks(0.0, 1.0, 3).unwrap().ticks.len() * 2;
let mark_offset = 82 + 17 + grid_count * 35;
assert_eq!(commands[mark_offset], 1);
assert_eq!(
u32::from_le_bytes(
commands[mark_offset + 1..mark_offset + 5]
.try_into()
.unwrap()
),
3
);
assert_eq!(commands[mark_offset + 33], 1);
assert!(commands.windows(12).any(|window| window == b"literal mesh"));

let painter = document.to_browser_painter(64 * 1024).unwrap();
assert_eq!(u32::from_le_bytes(painter[20..24].try_into().unwrap()), 2);
for group in 0..2 {
let descriptor = BROWSER_PAINTER_HEADER_BYTES + group * BROWSER_PAINTER_TRACE_BYTES;
assert_eq!(painter[descriptor], SceneRecordKind::PolyFill as u8);
assert_eq!(
u32::from_le_bytes(painter[descriptor + 4..descriptor + 8].try_into().unwrap()),
3
);
assert_eq!(
&painter[descriptor + 32..descriptor + 36],
&[34, 197, 94, 191]
);
assert_eq!(&painter[descriptor + 36..descriptor + 40], &[0, 0, 0, 0]);
assert_eq!(
f32::from_le_bytes(
painter[descriptor + 40..descriptor + 44]
.try_into()
.unwrap()
),
0.0
);
let id_offset = u32::from_le_bytes(
painter[descriptor + 24..descriptor + 28]
.try_into()
.unwrap(),
) as usize;
assert!((0..3).all(|row| {
u32::from_le_bytes(
painter[id_offset + row * 4..id_offset + row * 4 + 4]
.try_into()
.unwrap(),
) == 9 + group as u32
}));
}
let first_x_offset = u32::from_le_bytes(
painter[BROWSER_PAINTER_HEADER_BYTES + 8..BROWSER_PAINTER_HEADER_BYTES + 12]
.try_into()
.unwrap(),
) as usize;
assert!(
f32::from_le_bytes(
painter[first_x_offset..first_x_offset + 4]
.try_into()
.unwrap()
) < layout.left as f32
);
assert!(painter.windows(4).any(|window| window == b"XYLG"));
}

#[test]
fn polyfill_browser_groups_honor_the_canonical_trace_budget() {
let make_document = |triangle_count: usize| {
let layout = PlotLayout::new(120.0, 100.0, 10.0, 10.0, 10.0, 10.0).unwrap();
let record_count = triangle_count * 3;
let mut stable_ids = Vec::with_capacity(record_count);
for triangle in 0..triangle_count as u64 {
stable_ids.extend_from_slice(&[triangle, triangle, triangle]);
}
SceneDocument::decode(
&SceneBatch::new_with_decorations(
layout,
1,
2,
AxisScale::new(ScaleKind::Linear, 0.0, 1.0, 10.0, 110.0, 1.0, false).unwrap(),
AxisScale::new(ScaleKind::Linear, 0.0, 1.0, 90.0, 10.0, 1.0, false).unwrap(),
SceneChromeStyle::default(),
SceneChromeText::default(),
None,
&vec![SceneRecordKind::PolyFill as u8; record_count],
&stable_ids,
&vec![0; record_count],
&[34, 197, 94, 255],
&[0, 0, 0, 0],
&[0.0],
&vec![0.0; record_count],
&vec![0; record_count],
&vec![0.5; record_count],
&vec![0.5; record_count],
&vec![0.0; record_count],
&vec![0.0; record_count],
)
.unwrap()
.encode(),
)
.unwrap()
.to_raster_commands(1.0)
.unwrap();
assert!(commands.contains(&1));
};
assert_eq!(
u32::from_le_bytes(
make_document(MAX_BROWSER_PAINTER_TRACES)
.to_browser_painter(1024 * 1024)
.unwrap()[20..24]
.try_into()
.unwrap()
) as usize,
MAX_BROWSER_PAINTER_TRACES
);
assert_eq!(
make_document(MAX_BROWSER_PAINTER_TRACES + 1).to_browser_painter(1024 * 1024),
Err(SceneError::PainterTraceLimit)
);
}

#[test]
Expand Down
39 changes: 39 additions & 0 deletions packages/xy-node/test/scene.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,45 @@ test("Node matches Python bytes for all constant built-in scatter symbols", () =
assert.ok(sceneRasterCommands(scene).length > 100);
});

test("Node matches Python bytes for the bounded public literal triangle mesh", () => {
const figure = new Figure({ width: 360, height: 260 });
figure.setAxisDomain("x", [0, 2]); figure.setAxisDomain("y", [0, 2]);
figure.triangleMesh(
[-0.25, 1], [0.25, 0.5], [0.75, 2.25], [0.25, 0.5], [0.25, 1.5], [1.25, 1.75],
{ id: 0, name: "literal mesh", color: "#22c55e", opacity: 0.75 },
);
const scene = figure.toScene();
assert.equal(
crypto.createHash("sha256").update(scene).digest("hex"),
figureSceneFixture.public_triangle_mesh_sha256,
);
const svg = sceneSvg(scene);
assert.equal((svg.match(/<path d="M /g) ?? []).length, 2);
assert.match(svg, /<g clip-path="url\(#xy-scene-plot\)">/);
assert.match(svg, />literal mesh<\/text>/);
assert.ok(svg.indexOf("</g>", svg.indexOf("clip-path")) < svg.indexOf('data-xy-chrome="legend"'));
const raster = sceneRasterCommands(scene);
assert.ok(raster.length > 100);
assert.ok(Buffer.from(raster).includes(Buffer.from("literal mesh")));

const painter = sceneBrowserPainter(scene);
const view = new DataView(painter.buffer, painter.byteOffset, painter.byteLength);
const headerBytes = view.getUint32(12, true);
const descriptorBytes = view.getUint32(16, true);
assert.equal(view.getUint32(20, true), 2);
for (let group = 0; group < 2; group += 1) {
const descriptor = headerBytes + group * descriptorBytes;
assert.equal(painter[descriptor], 4);
assert.equal(view.getUint32(descriptor + 4, true), 3);
assert.deepEqual(Array.from(painter.subarray(descriptor + 32, descriptor + 36)), [34, 197, 94, 191]);
assert.deepEqual(Array.from(painter.subarray(descriptor + 36, descriptor + 40)), [0, 0, 0, 0]);
assert.equal(view.getFloat32(descriptor + 40, true), 0);
}
const firstXOffset = view.getUint32(headerBytes + 8, true);
assert.ok(view.getFloat32(firstXOffset, true) < view.getFloat32(32, true));
assert.ok(Buffer.from(painter).includes(Buffer.from("XYLG")));
});

test("Node numeric tick formats match Python bytes and every Rust Scene consumer", () => {
const figure = new Figure({ width: 420, height: 260 });
figure.setAxis("x", { domain: [0, 1], format: ".1%" });
Expand Down
49 changes: 45 additions & 4 deletions python/xyg/_scene_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
)
_STROKE_KINDS = frozenset({"line"}) | _SEGMENT_KINDS

# Each unjoined triangle is one PolyFill group in the Rust browser painter.
# Keep the public route inside its canonical group budget; larger meshes remain
# available through the screen-bounded compatibility path until Scene gains a
# compact multi-triangle painter record.
_MAX_PUBLIC_TRIANGLE_MESHES = 1024

_LEGEND_LOCATIONS = {
"upper right": 0,
"upper left": 1,
Expand Down Expand Up @@ -1559,9 +1565,10 @@ def scene_export_support_reason(
This is deliberately narrower than :func:`figure_scene`: the explicit
Scene API can exercise a migrating record before the public compatibility
renderer's complete output contract is modeled. The bounded literal
Cartesian geometry subset routes all constant built-in scatter symbols, polylines,
ordinary Rects, disconnected segment/error-bar/stem endpoint pairs, and
bounded solid ribbons expanded by Rust in axis-transformed space.
Cartesian geometry subset routes all constant built-in scatter symbols,
polylines, ordinary Rects, disconnected segment/error-bar/stem endpoint
pairs, bounded fill-only triangle meshes, and bounded solid ribbons
expanded by Rust in axis-transformed space.
The proven literal Cartesian chrome slice also routes automatically:
backgrounds, title, authored axes/ticks,
primary legend, literal colorbar, and the existing bounded primary
Expand Down Expand Up @@ -1694,6 +1701,7 @@ def scene_export_support_reason(
"area",
"error_band",
"ribbon",
"triangle_mesh",
}
public_style_keys = {
"scatter": {"color", "opacity", "symbol", "size", "role"},
Expand Down Expand Up @@ -1768,6 +1776,10 @@ def scene_export_support_reason(
"fill_opacity",
"stroke_opacity",
},
# The first public PolyFill slice is deliberately fill-only. Authored
# outlines and component alpha remain compatibility behavior until the
# Scene packing seam preserves their complete style contract.
"triangle_mesh": {"opacity", "role"},
}
has_literal_geometry = any(
trace.kind
Expand All @@ -1782,6 +1794,7 @@ def scene_export_support_reason(
"area",
"error_band",
"ribbon",
"triangle_mesh",
}
for trace in figure.traces
)
Expand All @@ -1794,6 +1807,7 @@ def scene_export_support_reason(
default_side = "bottom" if axis_id == "x" else "left"
if axis.get("domain") is None or axis.get("side") not in (None, default_side):
return "XYG_SCENE_UNSUPPORTED_PUBLIC_AXIS"
public_triangle_mesh_count = 0
for trace_index, trace in enumerate(figure.traces):
opacity = float((getattr(trace, "style", None) or {}).get("opacity", 1.0))
if not np.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
Expand Down Expand Up @@ -1827,6 +1841,22 @@ def scene_export_support_reason(
return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE"
if trace.kind in _RIBBON_KINDS and (trace.style or {}).get("role") != "ribbon":
return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE"
if trace.kind in _POLYFILL_KINDS:
mesh_columns = (trace.x0, trace.y0, trace.x1, trace.y1, trace.x, trace.y)
if any(column is None for column in mesh_columns):
return "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH"
mesh_lengths = {len(column.values) for column in mesh_columns}
mesh_count = next(iter(mesh_lengths), 0)
public_triangle_mesh_count += mesh_count
if (
len(mesh_lengths) != 1
or public_triangle_mesh_count > _MAX_PUBLIC_TRIANGLE_MESHES
or any(not np.isfinite(column.values).all() for column in mesh_columns)
):
return "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH"
mesh_style = trace.style or {}
if mesh_style.get("role") != "triangle-mesh" or mesh_style.get("joined_fill"):
return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE"
# The only generated companion scatter accepted here is the immediate
# endpoint marker for a preceding stem. This preserves author order
# and prevents a generic role from leaking into the public contract.
Expand Down Expand Up @@ -1857,7 +1887,7 @@ def scene_export_support_reason(
):
return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE"
try:
figure_scene(figure, width=width, height=height)
scene = figure_scene(figure, width=width, height=height)
except UnsupportedSceneV3 as unsupported:
return str(unsupported)
except ValueError as exc:
Expand All @@ -1868,6 +1898,17 @@ def scene_export_support_reason(
if str(exc) == "invalid canonical scene plot layout":
return "XYG_SCENE_UNSUPPORTED_VIEWPORT"
raise
if public_triangle_mesh_count:
# A PolyFill face is one Rust painter group. Ask the authoritative
# consumer as well as enforcing the face budget so a mixed figure near
# the boundary cannot pass preflight and then fragment past the shared
# descriptor limit.
try:
_native.scene_browser_painter(scene)
except ValueError as exc:
if str(exc) == "invalid canonical scene for browser painter":
return "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH"
raise
return None


Expand Down
6 changes: 3 additions & 3 deletions spec/api/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ raster-only option that was passed non-default.

| Format | Native backend | Chromium backend |
|---|---|---|
| PNG | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment exports use the Rust Scene raster display list: all 19 constant built-in scatter symbols, constant-style polylines (including literal steps), `bar`/`column`/`histogram`, and literal `segments`/error-bar/stem endpoint pairs with bounded built-in stem markers. Their bounded primary annotation family is unoffset plain text, Rust-positioned labelled rules/bands/markers, unlabeled straight arrows, ordinary callouts, and bounded wrapped text/callouts; every other supported native chart uses `_raster.to_png` → Rust rasterizer (`crates/xyg-engine/src/raster.rs`), encoded by the fused Rust path or `_png.encode`. | `Page.captureScreenshot` |
| PNG | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment/triangle-mesh/Band exports use the Rust Scene raster display list: all 19 constant built-in scatter symbols, constant-style polylines (including literal steps), `bar`/`column`/`histogram`, literal `segments`/error-bar/stem endpoint pairs with bounded built-in stem markers, ordinary area/error-band Bands, finite solid ribbons, and at most 1,024 total fill-only unjoined constant-color triangle faces. Their bounded primary annotation family is unoffset plain text, Rust-positioned labelled rules/bands/markers, unlabeled straight arrows, ordinary callouts, and bounded wrapped text/callouts; every other supported native chart uses `_raster.to_png` → Rust rasterizer (`crates/xyg-engine/src/raster.rs`), encoded by the fused Rust path or `_png.encode`. | `Page.captureScreenshot` |
| JPEG | `_raster.to_rgba` → `_jpeg.encode` (pure numpy/stdlib baseline JFIF, 4:4:4) | `Page.captureScreenshot` |
| WebP | `_raster.to_rgba` → `_webp.encode` (pure numpy/stdlib VP8L, **lossless only**) | `Page.captureScreenshot` (lossy) |
| SVG | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment exports use Rust Scene SVG, including the bounded primary annotation family listed for PNG. `FacetGrid` applies that same route independently to each supported, no-background-override panel and namespaces the closed Scene clip-id vocabulary before nested composition. `_svg.to_svg` remains the compatibility backend for every other panel. | none — SVG is native-only |
| PDF | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment exports consume Rust Scene SVG through `_pdf.svg_to_pdf`, including the bounded primary annotation family; this includes independently supported `FacetGrid` panels. The compatibility path is `_svg.to_svg` → `_pdf.svg_to_pdf`. | `Page.printToPDF` |
| SVG | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment/triangle-mesh/Band exports use Rust Scene SVG, including the bounded mesh, area/error-band, solid-ribbon, and annotation families listed for PNG. `FacetGrid` applies that same route independently to each supported, no-background-override panel and namespaces the closed Scene clip-id vocabulary before nested composition. `_svg.to_svg` remains the compatibility backend for every other panel. | none — SVG is native-only |
| PDF | Supported public literal Cartesian scatter/line/ordinary-rect/disconnected-segment/triangle-mesh/Band exports consume Rust Scene SVG through `_pdf.svg_to_pdf`, including the bounded mesh, area/error-band, solid-ribbon, and annotation families; this includes independently supported `FacetGrid` panels. The compatibility path is `_svg.to_svg` → `_pdf.svg_to_pdf`. | `Page.printToPDF` |

`_png.encode` auto-selects an indexed-palette PNG (color type 3 + `tRNS`) when
the image has ≤256 distinct RGBA colors. `optimize=True` selects this
Expand Down
Loading
Loading