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
90 changes: 72 additions & 18 deletions crates/xyg-engine/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3097,7 +3097,10 @@ pub fn validate_scene_batch(bytes: &[u8]) -> Result<SceneBatchSummary, SceneErro
}
match kind {
SceneRecordKind::Scatter => {
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);
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
);
Comment thread
DecisionNerd marked this conversation as resolved.
offset += 26;
}
}
Expand Down Expand Up @@ -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));
Expand Down
11 changes: 10 additions & 1 deletion packages/xy-node/src/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
40 changes: 40 additions & 0 deletions packages/xy-node/test/scene.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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), "");
Expand Down Expand Up @@ -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}</text>`));
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%" });
Expand Down
31 changes: 18 additions & 13 deletions python/xyg/_scene_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion spec/api/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions spec/design-dossier.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/design/host-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions spec/design/ownership-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading