diff --git a/crates/xyg-engine/src/scene.rs b/crates/xyg-engine/src/scene.rs index 5e82b243f..423faddd3 100644 --- a/crates/xyg-engine/src/scene.rs +++ b/crates/xyg-engine/src/scene.rs @@ -3097,7 +3097,10 @@ pub fn validate_scene_batch(bytes: &[u8]) -> Result { - if symbol > ScatterSymbol::X as u8 || coords[2] != 0.0 || coords[3] != 0.0 { + if symbol > ScatterSymbol::VerticalLine as u8 + || coords[2] != 0.0 + || coords[3] != 0.0 + { return Err(SceneError::Length); } } @@ -10170,23 +10173,33 @@ mod tests { 2, scale, scale, - &[0, 0, 0, 0], - &[1, 2, 3, 4], - &[0; 4], + &[0; 10], + &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + &[0; 10], &[0; 4], &[0; 4], &[0.0], - &[20.0; 4], + &[20.0; 10], &[ ScatterSymbol::Diamond as u8, ScatterSymbol::Diamond as u8, ScatterSymbol::ThinDiamond as u8, ScatterSymbol::ThinDiamond as u8, + ScatterSymbol::TriangleRight as u8, + ScatterSymbol::TriangleRight as u8, + ScatterSymbol::TriangleLeft as u8, + ScatterSymbol::TriangleLeft as u8, + ScatterSymbol::TriangleDown as u8, + ScatterSymbol::TriangleDown as u8, + ], + &[ + -12.0, -14.2, 40.0, 40.0, -9.9, -10.1, 89.9, 90.1, 40.0, 40.0, ], - &[-12.0, -14.2, 40.0, 40.0], - &[40.0, 40.0, -12.0, -14.2], - &[0.0; 4], - &[0.0; 4], + &[ + 40.0, 40.0, -12.0, -14.2, 40.0, 40.0, 40.0, 40.0, -9.9, -10.1, + ], + &[0.0; 10], + &[0.0; 10], ) .unwrap(); let encoded = batch.encode(); @@ -10195,6 +10208,13 @@ mod tests { assert_eq!(encoded[records + SCENE_BATCH_RECORD_BYTES + 1], 0); assert_eq!(encoded[records + 2 * SCENE_BATCH_RECORD_BYTES + 1], 1); assert_eq!(encoded[records + 3 * SCENE_BATCH_RECORD_BYTES + 1], 0); + for index in [4, 6, 8] { + assert_eq!(encoded[records + index * SCENE_BATCH_RECORD_BYTES + 1], 1); + assert_eq!( + encoded[records + (index + 1) * SCENE_BATCH_RECORD_BYTES + 1], + 0 + ); + } let line = MarkerGeometry::new(ScatterSymbol::PlusLine, 0.0, 0.0); assert_eq!(line.radius, 0.0); @@ -10534,7 +10554,7 @@ mod tests { &[0; 19], &[57, 135, 229, 255], &[0, 0, 0, 255], - &[1.0], + &[0.0], &[8.0; 19], &codes, &x, @@ -10544,16 +10564,41 @@ mod tests { ) .unwrap() .encode(); - let commands = SceneDocument::decode(&encoded) - .unwrap() - .to_raster_commands(1.0) - .unwrap(); + assert_eq!(validate_scene_batch(&encoded).unwrap().records, 19); + let document = SceneDocument::decode(&encoded).unwrap(); + let commands = document.to_raster_commands(1.0).unwrap(); + let painter = document.to_browser_painter(64 * 1024).unwrap(); + assert_eq!(u32::from_le_bytes(painter[20..24].try_into().unwrap()), 19); let grid_count = linear_ticks(0.0, 18.0, 3).unwrap().ticks.len() + linear_ticks(0.0, 1.0, 3).unwrap().ticks.len(); let mut offset = 82 + 17 + grid_count * 35; // two backgrounds, clip, grid for code in 0..=18 { assert_eq!(commands[offset], 4); assert_eq!(commands[offset + 13], code); + assert_eq!( + f32::from_le_bytes(commands[offset + 18..offset + 22].try_into().unwrap()), + if code >= ScatterSymbol::PlusLine as u8 { + 1.0 + } else { + 0.0 + } + ); + let descriptor = + BROWSER_PAINTER_HEADER_BYTES + code as usize * BROWSER_PAINTER_TRACE_BYTES; + assert_eq!(painter[descriptor], SceneRecordKind::Scatter as u8); + assert_eq!(painter[descriptor + 1], code); + assert_eq!( + f32::from_le_bytes( + painter[descriptor + 40..descriptor + 44] + .try_into() + .unwrap() + ), + if code >= ScatterSymbol::PlusLine as u8 { + 1.0 + } else { + 0.0 + } + ); offset += 26; } } @@ -11447,10 +11492,19 @@ mod tests { let mut nul_label = legend.clone(); nul_label.entries[0].label = "bad\0label".into(); assert_eq!(build(nul_label).err(), Some(SceneError::Limit)); - let mut boundary_symbol = legend.clone(); - boundary_symbol.entries[0].symbol = ScatterSymbol::VerticalLine as u8; - let boundary_encoded = build(boundary_symbol).unwrap().encode(); - assert!(SceneDocument::decode(&boundary_encoded).is_ok()); + for code in 0..=ScatterSymbol::VerticalLine as u8 { + let mut symbol_legend = legend.clone(); + symbol_legend.entries[0].symbol = code; + let symbol_encoded = build(symbol_legend).unwrap().encode(); + let symbol_document = SceneDocument::decode(&symbol_encoded).unwrap(); + assert!(symbol_document.to_svg().contains("role=\"listitem\"")); + assert!(symbol_document.to_raster_commands(1.0).is_ok()); + assert!(symbol_document + .to_browser_painter(16_384) + .unwrap() + .windows(4) + .any(|bytes| bytes == b"XYLG")); + } let mut invalid_scatter = legend.clone(); invalid_scatter.entries[0].symbol = ScatterSymbol::VerticalLine as u8 + 1; assert_eq!(build(invalid_scatter).err(), Some(SceneError::Length)); diff --git a/packages/xy-node/src/scene.js b/packages/xy-node/src/scene.js index d3088186f..4aff32507 100644 --- a/packages/xy-node/src/scene.js +++ b/packages/xy-node/src/scene.js @@ -611,11 +611,20 @@ export function figureSceneV3(figure, { margins = null } = {}) { const fillDefault = SEGMENT_KINDS.has(trace.kind) ? "#00000000" : color; const fillCss = style.fill ?? fillDefault; if (typeof fillCss !== "string") throw new RangeError(`Scene v12 does not yet encode ${trace.kind} non-CSS fills`); + const symbolCode = sceneSymbolCode(style.symbol ?? 0); const strokeCss = BAND_KINDS.has(trace.kind) ? (style.line_color ?? color) : RIBBON_KINDS.has(trace.kind) ? (style.stroke ?? color) - : (style.stroke ?? (STROKE_KINDS.has(trace.kind) ? color : "#00000000")); + : (style.stroke ?? ( + STROKE_KINDS.has(trace.kind) + || ( + trace.kind === "scatter" + && symbolCode >= SYMBOL_CODES.get("plus_line") + ) + ? color + : "#00000000" + )); const width = Number( style.stroke_width ?? style.width ?? style.line_width ?? (STROKE_KINDS.has(trace.kind) ? 1.5 : 0), ); diff --git a/packages/xy-node/test/scene.test.mjs b/packages/xy-node/test/scene.test.mjs index 88b2bc51c..020745fbe 100644 --- a/packages/xy-node/test/scene.test.mjs +++ b/packages/xy-node/test/scene.test.mjs @@ -11,6 +11,11 @@ const figureSceneFixture = JSON.parse(fs.readFileSync(new URL("../../../tests/fi const authoredSceneFixture = JSON.parse(fs.readFileSync(new URL("../../../tests/fixtures/authored_scene_v20.json", import.meta.url), "utf8")); const axisVisibilityFixture = JSON.parse(fs.readFileSync(new URL("../../../tests/fixtures/public_axis_visibility_scene.json", import.meta.url), "utf8")); const axisTickFixture = JSON.parse(fs.readFileSync(new URL("../../../tests/fixtures/axis_ticks.json", import.meta.url), "utf8")); +const BUILTIN_SYMBOLS = [ + "circle", "square", "diamond", "triangle", "cross", "hexagon", "pentagon", "star", + "triangle_down", "triangle_left", "triangle_right", "x", "point", "pixel", + "thin_diamond", "plus_line", "x_line", "horizontal_line", "vertical_line", +]; test("Node projects Rust-owned Scene support decisions verbatim", () => { assert.equal(sceneSupportReason(0), ""); @@ -68,6 +73,41 @@ test("Node figure compiles the exact shared scatter, line, bar Scene v4 fixture" assert.ok(sceneRasterCommands(encoded).length > 100); }); +test("Node matches Python bytes for all constant built-in scatter symbols", () => { + const figure = new Figure({ width: 760, height: 720 }); + figure.setAxisDomain("x", [-1, 19]); figure.setAxisDomain("y", [0, 1]); + for (const [code, symbol] of BUILTIN_SYMBOLS.entries()) { + figure.scatter([code], [0.5], { + id: code, + name: symbol, + style: { color: "#3987e5", size: 8, opacity: 1, symbol }, + }); + } + const scene = figure.toScene(); + assert.equal( + crypto.createHash("sha256").update(scene).digest("hex"), + figureSceneFixture.public_builtin_symbols_sha256, + ); + const svg = sceneSvg(scene); + assert.equal((svg.match(/role="listitem"/g) ?? []).length, 19); + for (const symbol of BUILTIN_SYMBOLS) assert.match(svg, new RegExp(`>${symbol}`)); + assert.ok((svg.match(/fill="none" stroke="rgb\(57,135,229\)" stroke-width="1"/g) ?? []).length >= 8); + + const painter = sceneBrowserPainter(scene); + const view = new DataView(painter.buffer, painter.byteOffset, painter.byteLength); + assert.equal(view.getUint32(20, true), 19); + const headerBytes = view.getUint32(12, true); + const descriptorBytes = view.getUint32(16, true); + for (let code = 0; code < 19; code += 1) { + const descriptor = headerBytes + code * descriptorBytes; + assert.equal(painter[descriptor], 0); + assert.equal(painter[descriptor + 1], code); + assert.equal(view.getFloat32(descriptor + 40, true), code >= 15 ? 1 : 0); + } + assert.ok(Buffer.from(painter).includes(Buffer.from("XYLG"))); + assert.ok(sceneRasterCommands(scene).length > 100); +}); + 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 a55062aa4..3050026d8 100644 --- a/python/xyg/_scene_v3.py +++ b/python/xyg/_scene_v3.py @@ -550,7 +550,17 @@ def figure_scene( ): raise ValueError("trace opacity channels must be finite and in [0, 1]") fill = _rgba(fill_value, opacity * fill_opacity) - stroke_default = color if trace.kind in _STROKE_KINDS else "transparent" + symbol_name = str(style.get("symbol", "circle")) + if symbol_name not in _SYMBOL_CODES: + raise UnsupportedSceneV3(f"Scene v12 does not support scatter symbol {symbol_name!r}") + stroke_default = ( + color + if trace.kind in _STROKE_KINDS + or ( + trace.kind == "scatter" and _SYMBOL_CODES[symbol_name] >= _SYMBOL_CODES["plus_line"] + ) + else "transparent" + ) if trace.kind in _RIBBON_KINDS: stroke_default = str(style.get("stroke", color)) elif trace.kind in _POLYFILL_KINDS: @@ -572,9 +582,6 @@ def figure_scene( stroke_width = float(width_value) styles.append((fill, stroke, stroke_width)) style_ref = len(styles) - 1 - symbol_name = str(style.get("symbol", "circle")) - if symbol_name not in _SYMBOL_CODES: - raise UnsupportedSceneV3(f"Scene v12 does not support scatter symbol {symbol_name!r}") diameter = ( float(trace.size_ch.constant) if trace.kind == "scatter" and trace.size_ch is not None @@ -1552,7 +1559,7 @@ 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 circle/diamond scatter, polylines, + 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. The proven literal Cartesian chrome slice also routes automatically: @@ -1837,14 +1844,12 @@ def scene_export_support_reason( ) ): return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE" - if trace.kind == "scatter" and (trace.style or {}).get("symbol", "circle") not in { - "circle", - "diamond", - }: - # Keep all remaining symbols on the compatibility route. In - # particular, line-only and asymmetric symbols have separate - # stroke/extent contracts that this public increment does not yet - # prove across static consumers. + if trace.kind == "scatter" and (trace.style or {}).get("symbol", "circle") not in ( + _SYMBOL_CODES + ): + # Custom marker paths/glyphs and data-driven symbol channels remain + # compatibility behavior. The fixed built-in vocabulary is fully + # represented by the canonical Scene record. return "XYG_SCENE_UNSUPPORTED_PUBLIC_SYMBOL" if any( value is not None and key not in public_style_keys[trace.kind] diff --git a/spec/api/export.md b/spec/api/export.md index d50f30e0e..0712cdbdd 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -62,7 +62,7 @@ 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: circle/diamond scatter, constant-style polylines (including literal steps), `bar`/`column`/`histogram`, and literal `segments`/error-bar/stem endpoint pairs with bounded 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 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` | | 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 | diff --git a/spec/design-dossier.md b/spec/design-dossier.md index bda6e2c9a..d30413bcb 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -541,11 +541,11 @@ F3, still pending (above). compiles constant-style cartesian scatter/line/bar figures in Python and Node, then exposes the exact same Scene v12 bytes to explicit Rust SVG and native-raster command consumers. Public static exports route the proven - literal Cartesian subset through those consumers: circle/diamond scatter, + 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 disconnected `segments`/error-bar/stem endpoint pairs (including the - immediately-following generated circle/diamond stem marker). Gradients, + 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 unmodeled marks retain their diff --git a/spec/design/host-parity.md b/spec/design/host-parity.md index 7a131345e..72713909c 100644 --- a/spec/design/host-parity.md +++ b/spec/design/host-parity.md @@ -96,7 +96,7 @@ and Node now compile the same representative constant-style scatter/line/bar figure fixture to identical Scene bytes; explicit host APIs feed those bytes to 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: -constant-style circle/diamond scatter and polylines, ordinary area/error-band +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, Python and Node pack two adjacent endpoint rows and ABI 97 makes Rust apply the diff --git a/spec/design/ownership-audit.md b/spec/design/ownership-audit.md index 0b1dbe3eb..5f60c2a20 100644 --- a/spec/design/ownership-audit.md +++ b/spec/design/ownership-audit.md @@ -32,11 +32,18 @@ Python and Node pack two adjacent compact endpoint rows, while Rust transforms the endpoints through the selected Cartesian axes and expands the fixed 96-interval cubic into ordinary Scene v25 Band samples. Host-local ribbon polygon helpers remain compatibility-renderer code, not canonical Scene policy. +The public constant built-in marker slice admits all 19 fixed symbol codes when +the scatter mark does not author a separate stroke or stroke width. Python and +Node preserve the constant fill paint in the Scene style table, including +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. 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 circle/diamond +literal Cartesian public geometry subset—constant-style built-in scatter symbols scatter and polylines, 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 @@ -58,7 +65,7 @@ the documented compatibility exceptions. `_svg.py`, `_raster.py`, and `_pdf.py` remain compatibility owners for rich text and legend variants, every annotation outside that bounded primary Cartesian family (including rotation, collision/layout directives, markup, CSS/classes, and custom typography), themes, custom fonts or CSS/classes, -nonliteral/custom chrome, symbols other than circle/diamond, unmodeled marks or +nonliteral/custom chrome, custom marker paths/glyphs, data-driven symbol channels, unmodeled marks or segment roles/styles, LOD inputs, export background overrides, and any other unmodeled output contract; #58/#117 must retire each exception only with cross-host differential and performance proof. diff --git a/spec/design/scene-ir.md b/spec/design/scene-ir.md index 2d60b2ffe..95f85162d 100644 --- a/spec/design/scene-ir.md +++ b/spec/design/scene-ir.md @@ -99,7 +99,12 @@ diameters/stroke widths must be finite and non-negative. The canonical marker policy shared with the version-1 SVG wrapper is: - line-only symbols (`plus_line`, `x_line`, `horizontal_line`, `vertical_line`) - use an implicit 1px stroke only when authored stroke width is zero; + use an implicit 1px stroke only when authored stroke width is zero; when no + stroke paint is authored, the thin host packing seams preserve the constant + fill paint as the stroke, while an explicitly transparent stroke stays + transparent. The current public static-export route admits the no-authored- + stroke case only; authored scatter stroke paint/width remains available at + the explicit Scene seam but stays on the compatibility renderer publicly; - path radius is `max(diameter / 2 - effective_stroke_width / 2, 0)`, with no hidden minimum-radius clamp; - most symbols have path x/y extent equal to radius; diamond uses `sqrt(2) × @@ -221,12 +226,12 @@ layout authority for that already-versioned bounded contract. `figureSceneV3` remains the Node packing seam and Rust remains the decoder, layout, and rendering authority. Public Python SVG/PNG/PDF route the proven literal Cartesian static contract -through Rust Scene: constant-style circle/diamond scatter; ordinary finite, +through Rust Scene: all 19 constant built-in scatter symbols; ordinary finite, 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 -circle/diamond marker; plus finite literal solid-color ribbons whose two-row +built-in constant marker; plus finite literal solid-color ribbons whose two-row host ingress Rust-expands after axis transformation. The geometry records are byte-identical for the shared Python/Node line+bar and disconnected-segment fixtures, with separate exact @@ -470,9 +475,9 @@ validates them and resolves leaders, label boxes, and wrapped line breaks. Unwrapped text offsets/anchors and marker-label offsets/anchors are not Scene fields and deliberately remain compatibility exceptions. Other annotation combinations/kinds, themes, classes/CSS/custom fonts, nonliteral chrome, -symbols other than circle/diamond, unmodeled marks, and LOD remain compatibility +custom marker paths/glyphs, data-driven symbol channels, unmodeled marks, and LOD remain compatibility preflight exceptions. Literal disconnected segments, error-bar stems/caps, -and stems with their immediate generated circle/diamond markers share the +and stems with their immediate generated constant built-in markers share the selected Scene with ordinary polylines and Rects; other segment-like roles and styles remain compatibility exceptions. ``try_public_svg`` / ``try_public_png`` / ``try_public_pdf`` expose the same consumers to callers diff --git a/tests/fixtures/figure_scene_v3.json b/tests/fixtures/figure_scene_v3.json index 66b60b58c..482cd1284 100644 --- a/tests/fixtures/figure_scene_v3.json +++ b/tests/fixtures/figure_scene_v3.json @@ -75,6 +75,7 @@ "numeric_tick_format_sha256": "1f7b76c8f529b34d2533e2b86c782b3ed2d82bfdccfbc92462de1cb2069d6a60", "nonlinear_axis_forwarding_sha256": "3820421d0781d81577ab46b85756f759da408e675f7f591ac22ae9e5b982ad8e", "public_disconnected_segments_sha256": "3b825fea6dbf97acfb128d7a248a86293994c5fdd277ab65d34597afc8d47629", + "public_builtin_symbols_sha256": "f95418305ccf42ba26cc848d3fd2e2d5c009524c9e2c9bd2715a1f65d7b55518", "band_outlines": { "top": { "sha256": "525e4382e9e46c211a7ebcef8d69d3ac14f7edef465b3a61c919084f84fdbd0c", diff --git a/tests/test_figure_scene_v3.py b/tests/test_figure_scene_v3.py index ce4a3186a..3fa2c3aaf 100644 --- a/tests/test_figure_scene_v3.py +++ b/tests/test_figure_scene_v3.py @@ -536,9 +536,10 @@ def test_public_router_preflights_legacy_export_contracts(mutate, reason: str) - assert reason in (_scene_v3.scene_export_support_reason(figure) or "") -def test_symbols_other_than_diamond_keep_the_legacy_rust_scatter_svg_contract() -> None: +def test_all_builtin_symbols_use_the_public_rust_scatter_contract() -> None: figure = Figure(width=320, height=240).scatter([1, 2], [2, 3], symbol="square") - assert "PUBLIC_SYMBOL" in (_scene_v3.scene_export_support_reason(figure) or "") + assert _scene_v3.scene_export_support_reason(figure) is None + assert figure.to_svg() == _scene_v3.figure_svg(figure) @pytest.mark.parametrize("factory", [public_callout_figure, public_authored_chrome_figure]) diff --git a/tests/test_scene_export_support.py b/tests/test_scene_export_support.py index 66bd8592f..526b366c2 100644 --- a/tests/test_scene_export_support.py +++ b/tests/test_scene_export_support.py @@ -27,6 +27,9 @@ figure_scene, scene_export_support_reason, ) +from xyg.marks import _SYMBOL_CODES + +BUILTIN_SYMBOLS = tuple(_SYMBOL_CODES) def _supported() -> Figure: @@ -37,6 +40,25 @@ def _supported() -> Figure: return figure +def _public_builtin_symbols() -> Figure: + """Every constant built-in symbol, with deterministic cross-host identity.""" + figure = Figure(width=760, height=720) + figure.axis_options["x"]["domain"] = (-1.0, 19.0) + figure.axis_options["y"]["domain"] = (0.0, 1.0) + for code, symbol in enumerate(BUILTIN_SYMBOLS): + figure.scatter( + [float(code)], + [0.5], + name=symbol, + color="#3987e5", + size=8, + opacity=1.0, + symbol=symbol, + ) + figure.traces[-1].id = code + return figure + + def _polar() -> Figure: figure = _supported() figure.coords = "polar" @@ -403,6 +425,87 @@ def test_public_solid_ribbon_routes_svg_png_pdf_through_the_exact_scene() -> Non assert figure.to_image(format="pdf") == _pdf.svg_to_pdf(svg) +def test_all_builtin_symbols_match_exact_cross_host_scene_and_public_consumers() -> None: + """The full fixed marker vocabulary leaves no Python static-policy fork.""" + from xyg import _native, _pdf, kernels + + fixture = json.loads((Path(__file__).parent / "fixtures" / "figure_scene_v3.json").read_text()) + figure = _public_builtin_symbols() + assert tuple(_SYMBOL_CODES.values()) == tuple(range(19)) + assert scene_export_support_reason(figure) is None + scene = figure_scene(figure) + assert hashlib.sha256(scene).hexdigest() == fixture["public_builtin_symbols_sha256"] + svg = _native.scene_svg(scene) + assert svg.count('role="listitem"') == 19 + assert all(f">{symbol}" in svg for symbol in BUILTIN_SYMBOLS) + assert svg.count('fill="none" stroke="rgb(57,135,229)" stroke-width="1"') >= 8 + 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) + assert int.from_bytes(painter[20:24], "little") == 19 + header_bytes = int.from_bytes(painter[12:16], "little") + descriptor_bytes = int.from_bytes(painter[16:20], "little") + for code in range(19): + descriptor = header_bytes + code * descriptor_bytes + assert painter[descriptor] == 0 + assert painter[descriptor + 1] == code + stroke_width = np.frombuffer(painter[descriptor + 40 : descriptor + 44], dtype="= 15 else 0.0) + assert b"XYLG" in painter + + +@pytest.mark.parametrize( + ("style_key", "style_value"), + [("stroke", "transparent"), ("stroke", "#ff0000"), ("stroke_width", 2.0)], +) +def test_authored_scatter_stroke_stays_on_compatibility_route( + style_key: str, style_value: str | float +) -> None: + """The all-symbol increment does not widen authored scatter paint policy.""" + figure = Figure(width=320, height=240) + figure.axis_options["x"]["domain"] = (0.0, 2.0) + figure.axis_options["y"]["domain"] = (0.0, 2.0) + figure.scatter([1.0], [1.0], symbol="plus_line", color="#3987e5") + figure.traces[-1].style[style_key] = style_value + assert scene_export_support_reason(figure) == "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE" + + +@pytest.mark.parametrize("symbol", BUILTIN_SYMBOLS) +def test_generated_stem_markers_route_every_builtin_symbol(symbol: str) -> None: + from xyg import _native + + figure = Figure(width=320, height=240) + figure.axis_options["x"]["domain"] = (0.0, 2.0) + figure.axis_options["y"]["domain"] = (0.0, 2.0) + figure.stem([1.0], [1.5], base=0.25, symbol=symbol) + assert figure.traces[-1].style.get("role") == "stem-marker" + assert scene_export_support_reason(figure) is None + assert figure.to_svg() == _native.scene_svg(figure_scene(figure)) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda figure: figure.traces[0].style.__setitem__("marker_path", {"contours": []}), + lambda figure: figure.traces[0].style.__setitem__("marker_glyph", "A"), + lambda figure: figure.scatter([2.5, 3.5], [2.5, 3.5], symbol=["circle", "square"]), + lambda figure: figure.scatter([2.5, 3.5], [2.5, 3.5], color=[0.0, 1.0]), + lambda figure: setattr(figure, "coords", "polar"), + lambda figure: figure.scatter(range(10_001), range(10_001)), + ], +) +def test_builtin_symbol_cutover_keeps_nonliteral_scatter_fail_closed( + mutate: Callable[[Figure], None], +) -> None: + figure = _supported() + mutate(figure) + assert scene_export_support_reason(figure) is not None + + @pytest.mark.parametrize( "mutate", [ @@ -523,7 +626,7 @@ def test_too_small_valid_export_viewport_is_a_documented_routing_exception() -> (lambda: _supported().histogram([0, 1, 1, 2], bins=2), None), (lambda: _supported().area([0, 1], [1, 2]), None), (lambda: _supported().error_band([0, 1], [0, 1], [1, 2]), None), - (lambda: _supported().scatter([0, 1], [1, 2], symbol="square"), "PUBLIC_SYMBOL"), + (lambda: _supported().scatter([0, 1], [1, 2], symbol="square"), None), (lambda: _supported().scatter([0, 1], [1, 2], symbol="diamond"), None), (lambda: _supported(), None), ], @@ -613,7 +716,6 @@ def test_public_router_routes_literal_disconnected_segments_through_all_static_c (lambda figure: figure.traces[0].style.__setitem__("dash", "4,2"), "PUBLIC_STYLE"), (lambda figure: figure.traces[0].style.__setitem__("role", "custom"), "PUBLIC_STYLE"), (lambda figure: figure.traces[0].x0.values.__setitem__(0, np.nan), "missing-data"), - (lambda figure: figure.traces[-1].style.__setitem__("symbol", "square"), "PUBLIC_SYMBOL"), ], ) def test_public_disconnected_segment_router_fails_closed(