diff --git a/crates/xyg-engine/src/scene.rs b/crates/xyg-engine/src/scene.rs index 423faddd3..e6d28041e 100644 --- a/crates/xyg-engine/src/scene.rs +++ b/crates/xyg-engine/src/scene.rs @@ -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, @@ -10994,24 +10994,44 @@ 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(); @@ -11019,14 +11039,130 @@ mod tests { u32::from_le_bytes(encoded[4..8].try_into().unwrap()), SCENE_VERSION ); - let svg = SceneDocument::decode(&encoded).unwrap().to_svg(); - assert!(svg.contains("")); + 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] diff --git a/packages/xy-node/test/scene.test.mjs b/packages/xy-node/test/scene.test.mjs index 020745fbe..d812344f8 100644 --- a/packages/xy-node/test/scene.test.mjs +++ b/packages/xy-node/test/scene.test.mjs @@ -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(//); + assert.match(svg, />literal mesh<\/text>/); + assert.ok(svg.indexOf("", 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%" }); diff --git a/python/xyg/_scene_v3.py b/python/xyg/_scene_v3.py index 3050026d8..d6c99132e 100644 --- a/python/xyg/_scene_v3.py +++ b/python/xyg/_scene_v3.py @@ -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, @@ -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 @@ -1694,6 +1701,7 @@ def scene_export_support_reason( "area", "error_band", "ribbon", + "triangle_mesh", } public_style_keys = { "scatter": {"color", "opacity", "symbol", "size", "role"}, @@ -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 @@ -1782,6 +1794,7 @@ def scene_export_support_reason( "area", "error_band", "ribbon", + "triangle_mesh", } for trace in figure.traces ) @@ -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: @@ -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. @@ -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: @@ -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 diff --git a/spec/api/export.md b/spec/api/export.md index 0712cdbdd..5cd183a55 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -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 diff --git a/spec/design-dossier.md b/spec/design-dossier.md index d30413bcb..814c2c484 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -543,11 +543,13 @@ F3, still pending (above). native-raster command consumers. Public static exports route the proven literal Cartesian subset through those consumers: all 19 constant built-in scatter symbols, constant-style polyline, ordinary area/error-band Bands, - bar/column/histogram rectangles, solid ribbons, and + bar/column/histogram rectangles, at most 1,024 fill-only unjoined + constant-color triangle-mesh faces, solid ribbons, and disconnected `segments`/error-bar/stem endpoint pairs (including the immediately-following generated constant built-in stem marker). Gradients, rounded corners, dashed or data-driven segment styles, LOD/density, - nonliteral palettes, two-ended ribbon gradients, polar geometry, and + nonliteral palettes, triangle-mesh component alpha/outlines/per-face styles/ + joined fills/larger batches, two-ended ribbon gradients, polar geometry, and unmodeled marks retain their compatibility renderers. Rust now owns chart/plot backgrounds, authored axis side/visibility and diff --git a/spec/design/host-parity.md b/spec/design/host-parity.md index 72713909c..eacb443ce 100644 --- a/spec/design/host-parity.md +++ b/spec/design/host-parity.md @@ -98,7 +98,11 @@ Rust SVG and native-raster consumers. Public Python SVG/PNG/PDF select the Rust Scene consumers only for the proven bounded literal Cartesian geometry subset: all 19 constant built-in scatter symbols and constant-style polylines, ordinary area/error-band Bands, bar/column/histogram Rects, disconnected segment/error-bar/stem endpoint -pairs with bounded stem markers, and finite literal solid ribbons. For ribbons, +pairs with bounded stem markers, at most 1,024 fill-only unjoined constant-color +triangle-mesh faces, and finite literal solid ribbons. Each accepted mesh face +is one three-vertex PolyFill group shared by SVG, raster, and browser consumers; +joined fills, component alpha, outlines, per-face styles, alternate axes, and +larger meshes remain compatibility behavior. For ribbons, Python and Node pack two adjacent endpoint rows and ABI 97 makes Rust apply the axis transforms and expand the fixed 96-interval cubic into 97 paired Scene Band samples. Two-ended gradients, polar projection, LOD/density, and diff --git a/spec/design/ownership-audit.md b/spec/design/ownership-audit.md index 5f60c2a20..73801c0d7 100644 --- a/spec/design/ownership-audit.md +++ b/spec/design/ownership-audit.md @@ -39,12 +39,19 @@ fill-as-stroke for line-only symbols, while Rust owns implicit 1px line-only width, symbol paths, extent-aware clipping, legend swatches, and SVG/raster/browser lowering. Authored scatter stroke paint/width remains on the compatibility route for a later bounded cutover. +The public literal triangle-mesh slice admits at most 1,024 unjoined faces with +one constant fill and scalar overall opacity. Python and Node pack six authored +vertex columns as three-row PolyFill runs; Rust owns their stable-run grouping, +plot clipping, legend swatch, and SVG/raster/browser lowering. Joined fills, +component alpha, authored outlines, per-face paint/style, alternate axes, and +larger meshes remain compatibility behavior. Static-export routing status (#117): `Figure.to_svg`, native `to_png`, native `to_image(..., "svg"|"png"|"pdf")`, `write_image`, and the native branch of `write_images` now delegate the proven literal Cartesian public geometry subset—constant-style built-in scatter symbols -scatter and polylines, ordinary finite fixed-domain area/error-band Bands, +and polylines, bounded fill-only unjoined triangle meshes, ordinary finite +fixed-domain area/error-band Bands, ordinary bar/column/histogram Rects, bounded disconnected segment/error-bar/stem endpoint pairs, and finite literal solid-color ribbons expanded by Rust—plus the proven literal static diff --git a/spec/design/scene-ir.md b/spec/design/scene-ir.md index 95f85162d..d14dd6898 100644 --- a/spec/design/scene-ir.md +++ b/spec/design/scene-ir.md @@ -231,13 +231,22 @@ fixed-domain area/error-band Bands; constant-style polyline (including Rust-expanded literal steps); and the ordinary Rect family (`bar`/`column`/`histogram`); plus bounded literal disconnected endpoint pairs for `segments`, error-bar stems/caps, and `stem` with its immediate generated -built-in constant marker; plus finite literal solid-color ribbons whose two-row -host ingress Rust-expands after axis transformation. The geometry records are +built-in constant marker; at most 1,024 finite unjoined `triangle_mesh` faces +with a constant fill and scalar overall opacity; plus finite literal solid-color +ribbons whose two-row host ingress Rust-expands after axis transformation. Each +mesh face is one three-row PolyFill group, matching the Rust browser painter's +1,024-group bound; public selection also asks that authoritative consumer to +validate the complete mixed-figure group budget before routing. Joined fills, +component alpha, authored outlines, per-face +paint/style, alternate axes, and larger meshes stay on the compatibility route. +The geometry records are byte-identical for the shared -Python/Node line+bar and disconnected-segment fixtures, with separate exact -cross-host fixtures for step expansion, histogram bins, and Python -`column`/Node `bar` Rect equivalence. Newly selected line, Rect, endpoint-pair, -and ribbon figures require explicit Cartesian domains on the default axis sides +Python/Node line+bar, disconnected-segment, and `public_triangle_mesh_sha256` +fixtures, with separate exact cross-host fixtures for step expansion, histogram +bins, and Python +`column`/Node `bar` Rect equivalence. Newly selected line, Rect, Band, +endpoint-pair, mesh, and ribbon figures require explicit Cartesian domains on the +default axis sides and must remain inside the bounded host-input and expanded-record budgets. For segment-family traces, one row is one emitted endpoint pair, so generated error-bar cap pairs count toward that diff --git a/tests/fixtures/figure_scene_v3.json b/tests/fixtures/figure_scene_v3.json index 482cd1284..b21a44dd0 100644 --- a/tests/fixtures/figure_scene_v3.json +++ b/tests/fixtures/figure_scene_v3.json @@ -76,6 +76,7 @@ "nonlinear_axis_forwarding_sha256": "3820421d0781d81577ab46b85756f759da408e675f7f591ac22ae9e5b982ad8e", "public_disconnected_segments_sha256": "3b825fea6dbf97acfb128d7a248a86293994c5fdd277ab65d34597afc8d47629", "public_builtin_symbols_sha256": "f95418305ccf42ba26cc848d3fd2e2d5c009524c9e2c9bd2715a1f65d7b55518", + "public_triangle_mesh_sha256": "eb25e8405b61d419b9839c5fba89f5920810a55f655aea3e5f7ba6ddcfea6ff0", "band_outlines": { "top": { "sha256": "525e4382e9e46c211a7ebcef8d69d3ac14f7edef465b3a61c919084f84fdbd0c", diff --git a/tests/test_scene_export_support.py b/tests/test_scene_export_support.py index 526b366c2..bbd7e6dec 100644 --- a/tests/test_scene_export_support.py +++ b/tests/test_scene_export_support.py @@ -59,6 +59,32 @@ def _public_builtin_symbols() -> Figure: return figure +def _public_triangle_mesh(count: int = 2) -> Figure: + """Literal unjoined PolyFill rows with deterministic cross-host identity.""" + figure = Figure(width=360, height=260) + figure.axis_options["x"]["domain"] = (0.0, 2.0) + figure.axis_options["y"]["domain"] = (0.0, 2.0) + x0 = np.resize(np.asarray([-0.25, 1.0], dtype=np.float64), count) + y0 = np.resize(np.asarray([0.25, 0.5], dtype=np.float64), count) + x1 = np.resize(np.asarray([0.75, 2.25], dtype=np.float64), count) + y1 = np.resize(np.asarray([0.25, 0.5], dtype=np.float64), count) + x2 = np.resize(np.asarray([0.25, 1.5], dtype=np.float64), count) + y2 = np.resize(np.asarray([1.25, 1.75], dtype=np.float64), count) + figure.triangle_mesh( + x0, + y0, + x1, + y1, + x2, + y2, + name="literal mesh", + color="#22c55e", + opacity=0.75, + ) + figure.traces[-1].id = 0 + return figure + + def _polar() -> Figure: figure = _supported() figure.coords = "polar" @@ -458,6 +484,156 @@ def test_all_builtin_symbols_match_exact_cross_host_scene_and_public_consumers() assert b"XYLG" in painter +def test_public_triangle_mesh_matches_exact_cross_host_scene_and_consumers() -> None: + """Two clipped literal triangles keep one canonical run per face.""" + from xyg import _native, _pdf, kernels + + fixture = json.loads((Path(__file__).parent / "fixtures" / "figure_scene_v3.json").read_text()) + figure = _public_triangle_mesh() + assert scene_export_support_reason(figure) is None + scene = figure_scene(figure) + assert hashlib.sha256(scene).hexdigest() == fixture["public_triangle_mesh_sha256"] + + svg = _native.scene_svg(scene) + assert svg.count('' in svg + assert svg.count('role="listitem"') == 1 + assert ">literal mesh" in svg + assert figure.to_svg() == svg + assert figure.to_png(scale=1) == kernels.rasterize_png( + _native.scene_raster_commands(scene), figure.width, figure.height + ) + assert figure.to_image(format="pdf") == _pdf.svg_to_pdf(svg) + + painter = _native.scene_browser_painter(scene) + header_bytes = int.from_bytes(painter[12:16], "little") + descriptor_bytes = int.from_bytes(painter[16:20], "little") + assert int.from_bytes(painter[20:24], "little") == 2 + plot_left = np.frombuffer(painter[32:36], dtype=" None: + from xyg import _native + + boundary = _public_triangle_mesh(1024) + assert scene_export_support_reason(boundary) is None + painter = _native.scene_browser_painter(figure_scene(boundary)) + assert int.from_bytes(painter[20:24], "little") == 1024 + assert ( + scene_export_support_reason(_public_triangle_mesh(1025)) + == "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH" + ) + + aggregate = _public_triangle_mesh(513) + trace = aggregate.traces[0] + aggregate.triangle_mesh( + trace.x0.values, + trace.y0.values, + trace.x1.values, + trace.y1.values, + trace.x.values, + trace.y.values, + color="#22c55e", + ) + assert scene_export_support_reason(aggregate) == "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH" + + mixed = _public_triangle_mesh(1024) + mixed.scatter([1.0], [1.0], color="#3987e5") + assert scene_export_support_reason(mixed) == "XYG_SCENE_UNSUPPORTED_PUBLIC_TRIANGLE_MESH" + + +@pytest.mark.parametrize( + ("style_key", "style_value"), + [ + ("joined_fill", True), + ("fill_opacity", 0.5), + ("stroke_opacity", 0.5), + ("stroke", "#ff0000"), + ("stroke_width", 2.0), + ("role", "custom-mesh"), + ], +) +def test_public_triangle_mesh_keeps_broader_styles_on_compatibility( + style_key: str, style_value: object +) -> None: + figure = _public_triangle_mesh() + figure.traces[0].style[style_key] = style_value + assert scene_export_support_reason(figure) == "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE" + + +@pytest.mark.parametrize( + "factory", + [ + lambda: Figure(width=360, height=260).triangle_mesh( + [0, 1], [0, 0], [0.5, 1.5], [1, 1], [1, 2], [0, 0], color=[0.0, 1.0] + ), + lambda: Figure(width=360, height=260).triangle_mesh( + [0, 1], + [0, 0], + [0.5, 1.5], + [1, 1], + [1, 2], + [0, 0], + color=np.asarray([[1, 0, 0, 1], [0, 1, 0, 1]], dtype=np.float64), + ), + lambda: Figure(width=360, height=260).triangle_mesh( + [0, 1], [0, 0], [0.5, 1.5], [1, 1], [1, 2], [0, 0], opacity=[0.5, 1.0] + ), + lambda: Figure(width=360, height=260).triangle_mesh( + [0, 1], + [0, 0], + [0.5, 1.5], + [1, 1], + [1, 2], + [0, 0], + stroke=["#ff0000", "#00ff00"], + ), + lambda: Figure(width=360, height=260).triangle_mesh( + [0, 1], + [0, 0], + [0.5, 1.5], + [1, 1], + [1, 2], + [0, 0], + stroke_width=[1.0, 2.0], + ), + ], +) +def test_public_triangle_mesh_keeps_per_item_paint_on_compatibility(factory) -> None: + figure = factory() + figure.axis_options["x"]["domain"] = (0.0, 2.0) + figure.axis_options["y"]["domain"] = (0.0, 2.0) + assert scene_export_support_reason(figure) is not None + + +@pytest.mark.parametrize( + "mutate", + [ + lambda figure: setattr(figure, "coords", "polar"), + lambda figure: setattr(figure.traces[0], "x_axis", "x2"), + lambda figure: figure.axis_options["x"].__setitem__("domain", None), + lambda figure: figure.axis_options["y"].__setitem__("domain", None), + lambda figure: setattr(figure.traces[0], "hidden", True), + lambda figure: figure.traces[0].x0.values.__setitem__(0, np.nan), + lambda figure: setattr(figure.traces[0].x0, "values", np.asarray([0.0])), + ], +) +def test_public_triangle_mesh_keeps_nonliteral_geometry_fail_closed( + mutate: Callable[[Figure], None], +) -> None: + figure = _public_triangle_mesh() + mutate(figure) + assert scene_export_support_reason(figure) is not None + + @pytest.mark.parametrize( ("style_key", "style_value"), [("stroke", "transparent"), ("stroke", "#ff0000"), ("stroke_width", 2.0)],