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
27 changes: 27 additions & 0 deletions crates/xyg-engine/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10221,6 +10221,33 @@ mod tests {
assert_eq!(line.stroke_width, 1.0);
assert_eq!(line.extent_x, 0.5);
assert_eq!(line.extent_y, 0.5);

// An authored non-zero stroke is part of the outer marker diameter:
// the path shrinks by half the width and clipping adds it back. Keep
// the exact overlap boundary pinned for the public scatter-stroke route.
let stroked = SceneBatch::new(
layout,
1,
2,
scale,
scale,
&[0; 2],
&[11, 12],
&[0; 2],
&[51, 102, 153, 255],
&[255, 136, 0, 255],
&[6.0],
&[20.0; 2],
&[ScatterSymbol::Circle as u8; 2],
&[-9.9, -10.1],
&[40.0; 2],
&[0.0; 2],
&[0.0; 2],
)
.unwrap()
.encode();
assert_eq!(stroked[records + 1], 1);
assert_eq!(stroked[records + SCENE_BATCH_RECORD_BYTES + 1], 0);
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions packages/xy-node/src/figure.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from "./pyramid.js";
import { composeGraph } from "./graph.js";
import { composeSankey } from "./sankey.js";
import { composeScatter } from "./marks/scatter.js";
import { composeScatter, normalizeScatterStyle } from "./marks/scatter.js";
import { composeLine, F64_EPS } from "./marks/line.js";
import { composeHistogram } from "./marks/histogram.js";
import { composeArea } from "./marks/area.js";
Expand Down Expand Up @@ -341,7 +341,7 @@ export class Figure {
name: opts.name ?? null,
x: asF64(x),
y: asF64(y),
style: { ...(opts.style ?? {}) },
style: normalizeScatterStyle(opts.style),
x_axis: opts.xAxis ?? "x",
y_axis: opts.yAxis ?? "y",
...(opts.color != null ? { color: opts.color } : {}),
Expand Down
10 changes: 9 additions & 1 deletion packages/xy-node/src/marks/scatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@

import { asF64Array, encodeF32Values, minMax } from "../encode.js";

export function normalizeScatterStyle(style = {}) {
const normalized = { ...style };
if (normalized.stroke != null && normalized.stroke_width == null) {
normalized.stroke_width = 1;
}
return normalized;
}

function optionalBoolean(value, name) {
if (value == null) return undefined;
if (typeof value !== "boolean") {
Expand Down Expand Up @@ -39,7 +47,7 @@ export function composeScatter(x, y, opts = {}) {
name: opts.name ?? null,
x: xa,
y: ya,
style: { opacity: 0.8, ...(opts.style ?? {}) },
style: normalizeScatterStyle({ opacity: 0.8, ...(opts.style ?? {}) }),
x_axis: opts.xAxis ?? "x",
y_axis: opts.yAxis ?? "y",
...(forceDensity != null ? { force_density: Boolean(forceDensity) } : {}),
Expand Down
5 changes: 4 additions & 1 deletion packages/xy-node/src/scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,10 @@ export function figureSceneV3(figure, { margins = null } = {}) {
: "#00000000"
));
const width = Number(
style.stroke_width ?? style.width ?? style.line_width ?? (STROKE_KINDS.has(trace.kind) ? 1.5 : 0),
style.stroke_width
?? style.width
?? style.line_width
?? (STROKE_KINDS.has(trace.kind) ? 1.5 : 0),
);
styles.push({
fillRgba: rgba8(fillCss, opacity * fillOpacity, "fill"),
Expand Down
28 changes: 28 additions & 0 deletions packages/xy-node/test/scene.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,34 @@ test("Node figure defaults match Python Scene bytes and canonical values", () =>
assert.equal(new DataView(line.buffer, line.byteOffset).getFloat64(168, true), 1.5);
});

test("Node constant scatter stroke matches Python bytes and public defaults", () => {
const figure = new Figure({ width: 320, height: 240 });
figure.setAxisDomain("x", [0, 2]); figure.setAxisDomain("y", [0, 2]);
figure.scatter([0.25, 1.75], [0.5, 1.5], {
id: 41, name: "outlined",
style: { color: "#336699", opacity: 0.75, size: 12, symbol: "diamond", stroke: "#ff8800", stroke_width: 3.5 },
});
const scene = figure.toScene();
assert.equal(crypto.createHash("sha256").update(scene).digest("hex"), figureSceneFixture.public_scatter_stroke_sha256);
const svg = sceneSvg(scene);
assert.match(svg, /stroke="rgb\(255,136,0\)" stroke-opacity="0\.75"/);
assert.match(svg, /stroke-width="3\.5"/);
assert.match(svg, /outlined/);
assert.ok(sceneRasterCommands(scene).byteLength > 0);
assert.ok(sceneBrowserPainter(scene).byteLength > 0);

const strokeOnly = new Figure({ width: 320, height: 240 });
strokeOnly.scatter([0.5], [0.5], { style: { color: "#336699", stroke: "#ff8800" } });
assert.equal(strokeOnly.traces[0].style.stroke_width, 1);
assert.equal(new DataView(strokeOnly.toScene().buffer).getFloat64(168, true), 1);

for (const strokeWidth of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
const invalid = new Figure({ width: 320, height: 240 });
invalid.scatter([0.5], [0.5], { style: { stroke: "#ff8800", stroke_width: strokeWidth } });
assert.throws(() => invalid.toScene(), /invalid canonical scene batch/);
}
});

test("Node frames the literal Scene colorbar side before Rust reserves its lane", () => {
for (const [side, offset, viewport] of [["right", 64, 320], ["bottom", 72, 240]]) {
const figure = new Figure({ width: 320, height: 240 });
Expand Down
11 changes: 10 additions & 1 deletion python/xyg/_scene_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1704,7 +1704,7 @@ def scene_export_support_reason(
"triangle_mesh",
}
public_style_keys = {
"scatter": {"color", "opacity", "symbol", "size", "role"},
"scatter": {"color", "opacity", "symbol", "size", "role", "stroke", "stroke_width"},
# A literal ``step`` is expanded before Scene packing; Rust then owns
# the resulting polyline, clipping, raster, and SVG policy.
"line": {"color", "opacity", "width", "step"},
Expand Down Expand Up @@ -1881,6 +1881,15 @@ def scene_export_support_reason(
# compatibility behavior. The fixed built-in vocabulary is fully
# represented by the canonical Scene record.
return "XYG_SCENE_UNSUPPORTED_PUBLIC_SYMBOL"
if (
trace.kind == "scatter"
and (trace.style or {}).get("stroke_width") is not None
and (trace.style or {}).get("stroke") is None
):
# Width-only scatter authoring is a match-fill channel. Keep that
# semantic on the compatibility renderer until Scene represents
# it explicitly rather than inferring paint in the host router.
return "XYG_SCENE_UNSUPPORTED_PUBLIC_STYLE"
if any(
value is not None and key not in public_style_keys[trace.kind]
for key, value in (getattr(trace, "style", None) or {}).items()
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/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` |
| 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 either without authored stroke or with an authored constant CSS stroke and optional finite non-negative scalar width (default 1px), 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/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 |
Expand Down
4 changes: 3 additions & 1 deletion spec/design-dossier.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,9 @@ 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: all 19 constant built-in scatter symbols,
literal Cartesian subset through those consumers: all 19 constant built-in scatter symbols
either without authored stroke or with an authored literal constant CSS
stroke and optional finite non-negative scalar width (default 1px),
constant-style polyline, ordinary area/error-band Bands,
bar/column/histogram rectangles, at most 1,024 fill-only unjoined
constant-color triangle-mesh faces, solid ribbons, and
Expand Down
8 changes: 6 additions & 2 deletions spec/design/host-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ 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:
all 19 constant built-in scatter symbols and constant-style polylines, ordinary area/error-band
all 19 constant built-in scatter symbols either without authored stroke or with
an authored constant CSS stroke and optional finite non-negative scalar width
(default 1px), and constant-style
polylines, ordinary area/error-band
Bands, bar/column/histogram Rects, disconnected segment/error-bar/stem endpoint
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
Expand Down Expand Up @@ -129,7 +132,8 @@ Extra legends, named/advanced colorbars, other deferred
compilers route real polar, custom-font, CSS/class, and normalized gradient
representations through it and reject non-u32 request versions before FFI
coercion. See
[scene-ir.md](scene-ir.md). Python custom glyph/path markers and other
[scene-ir.md](scene-ir.md). Per-item scatter stroke/width, custom marker
paths/glyphs, and density/LOD remain explicit compatibility exceptions. Python custom glyph/path markers and other
not-yet-migrated customization remain explicit compatibility exceptions until
bounded path, text, and chrome records land.

Expand Down
15 changes: 8 additions & 7 deletions spec/design/ownership-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ 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.
The public constant built-in marker slice admits all 19 fixed symbol codes and
an optional literal constant CSS stroke with a finite non-negative scalar
width. Python and Node preserve the constant fill and stroke paint in the Scene
style table and normalize stroke-only authoring to 1px. Rust owns implicit 1px line-only width,
symbol paths, stroke-inclusive extent clipping, legend swatches, and
SVG/raster/browser lowering. Width-only match-fill, per-item stroke/width,
custom paths/glyphs, and density/LOD scatter remain compatibility routes.
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,
Expand All @@ -50,6 +50,7 @@ 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
with optional constant marker strokes and scalar widths
and polylines, bounded fill-only unjoined triangle meshes, ordinary finite
fixed-domain area/error-band Bands,
ordinary bar/column/histogram Rects, bounded disconnected
Expand Down
9 changes: 6 additions & 3 deletions spec/design/scene-ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ policy shared with the version-1 SVG wrapper is:
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;
stroke case. The public bounded constant-scatter slice also admits a literal
constant CSS stroke with a finite non-negative scalar width; an authored
stroke with no width uses the public 1px default. Width-only match-fill and
per-item stroke/width remain compatibility;
- 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) ×
Expand Down Expand Up @@ -226,7 +228,8 @@ 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: all 19 constant built-in scatter symbols; ordinary finite,
through Rust Scene: all 19 constant built-in scatter symbols, including bounded
constant CSS marker strokes and scalar widths; 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
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/figure_scene_v3.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"nonlinear_axis_forwarding_sha256": "3820421d0781d81577ab46b85756f759da408e675f7f591ac22ae9e5b982ad8e",
"public_disconnected_segments_sha256": "3b825fea6dbf97acfb128d7a248a86293994c5fdd277ab65d34597afc8d47629",
"public_builtin_symbols_sha256": "f95418305ccf42ba26cc848d3fd2e2d5c009524c9e2c9bd2715a1f65d7b55518",
"public_scatter_stroke_sha256": "31172f14e2c8104c3d4ff504db12d618b77bc4b6b6e387361464f98ab114c001",
"public_triangle_mesh_sha256": "eb25e8405b61d419b9839c5fba89f5920810a55f655aea3e5f7ba6ddcfea6ff0",
"band_outlines": {
"top": {
Expand Down
6 changes: 4 additions & 2 deletions tests/test_css_mark_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ def test_css_mark_style_reaches_svg_and_native_renderers() -> None:
).figure()

svg = fig.to_svg()
assert 'fill="#22c55e"' in svg
assert 'stroke="#052e16"' in svg
# The public constant-stroke slice now lowers literal CSS paint through
# the canonical RGBA style table; spelling changes, paint semantics do not.
assert 'fill="rgb(34,197,94)"' in svg
assert 'stroke="rgb(5,46,22)"' in svg
assert 'stroke-width="2"' in svg

image = _raster.render_raster(*fig.build_payload(), scale=1)
Expand Down
79 changes: 79 additions & 0 deletions tests/test_figure_scene_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,85 @@ def test_all_builtin_symbols_use_the_public_rust_scatter_contract() -> None:
assert figure.to_svg() == _scene_v3.figure_svg(figure)


def test_constant_scatter_stroke_uses_the_public_rust_scene_contract() -> None:
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(
[0.25, 1.75],
[0.5, 1.5],
color="#336699",
opacity=0.75,
size=12,
symbol="diamond",
stroke="#ff8800",
stroke_width=3.5,
name="outlined",
)
figure.traces[-1].id = 41
scene = figure.to_scene()
assert hashlib.sha256(scene).hexdigest() == FIXTURE["public_scatter_stroke_sha256"]
assert _scene_v3.scene_export_support_reason(figure) is None
svg = _native.scene_svg(scene)
assert 'stroke="rgb(255,136,0)" stroke-opacity="0.75"' in svg
assert 'stroke-width="3.5"' in svg
assert "outlined" in svg
assert figure.to_svg() == svg
assert figure.to_png(scale=1) == _scene_v3.try_public_png(figure, scale=1)
assert figure.to_image(format="pdf") == _scene_v3.try_public_pdf(figure)
assert _native.scene_raster_commands(scene)
assert _native.scene_browser_painter(scene)


def test_constant_scatter_stroke_defaults_and_compatibility_boundaries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stroke_only = Figure(width=320, height=240).scatter(
[0.5], [0.5], color="#336699", stroke="#ff8800"
)
assert stroke_only.traces[-1].style["stroke_width"] == 1.0
assert _scene_v3.scene_export_support_reason(stroke_only) is None

width_only = Figure(width=320, height=240).scatter(
[0.5], [0.5], color="#336699", stroke_width=2.0
)
assert "PUBLIC_STYLE" in (_scene_v3.scene_export_support_reason(width_only) or "")

plain_line_symbol = Figure(width=320, height=240).scatter(
[0.5], [0.5], color="#336699", symbol="plus_line"
)
plain_svg = _scene_v3.figure_svg(plain_line_symbol)
assert 'stroke="rgb(51,102,153)" stroke-opacity="0.8"' in plain_svg
assert 'stroke-width="1"' in plain_svg

compatibility = []
for kwargs in (
{"stroke": ["#111111", "#222222"]},
{"stroke_width": [1.0, 2.0]},
):
figure = Figure(width=320, height=240).scatter([0.5, 1.0], [0.5, 1.0], **kwargs)
assert _scene_v3.scene_export_support_reason(figure) is not None
compatibility.append(figure)
compatibility.append(width_only)

for opacity_key in ("fill_opacity", "stroke_opacity"):
figure = Figure(width=320, height=240).scatter([0.5], [0.5])
figure.traces[-1].style[opacity_key] = 0.5
assert "PUBLIC_STYLE" in (_scene_v3.scene_export_support_reason(figure) or "")
compatibility.append(figure)

def unexpected_scene(*_args: object, **_kwargs: object) -> str:
raise AssertionError("per-item scatter styling must stay on compatibility")

monkeypatch.setattr(_native, "scene_svg", unexpected_scene)
for figure in compatibility:
assert figure.to_svg().startswith("<svg")

for invalid_width in (-1.0, float("nan"), float("inf")):
with pytest.raises(ValueError, match="scatter stroke_width"):
Figure().scatter([0.5], [0.5], stroke_width=invalid_width)


@pytest.mark.parametrize("factory", [public_callout_figure, public_authored_chrome_figure])
def test_supported_public_exports_match_rust_consumers_and_are_repeatable(factory) -> None:
"""The public journey must not merely produce valid files beside Scene."""
Expand Down
Loading
Loading