From 552db467f7aab50209efb72e027a86e1a75dde23 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 01:44:40 +0000 Subject: [PATCH] Widen the mark CSS subset: stroke-linecap and marker-shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `styles.py` accepted nine mark properties and no way to add a tenth without touching three renderers by hand. This adds the two the renderers can honestly agree on today and, in doing so, settles a cap disagreement they had been hiding. `marker-shape` was free: all three renderers have drawn 17 marker symbols for some time and `symbol=` already reached every one of them. Only the CSS spelling was missing, and both spellings compile to the same trace-style value, so the two build byte-identical specs. `stroke-linecap` was not free, and the reason is worth recording. The three renderers disagreed and none of them said so: the native rasterizer capped round (its clamped segment distance field is a capsule), the WebGL client capped butt with a half-pixel bleed, and the SVG writer hardcoded `round` on line paths while the area outline inherited SVG's `butt` — which `_pdf` then read back as `butt` as well. XY's default is now `round` everywhere, chosen because the rasterizer is the reference for static export, and every stroked SVG path names both cap and join instead of letting the format decide. The rasterizer keeps its existing fast path untouched for round/round and routes anything else through `stroke_shaped`, which decomposes a polyline into oriented-box segments plus join primitives (disc, bevel triangle, miter wedge with SVG's limit of 4) max-combined into one scratch buffer, so overlapping join geometry cannot double-darken a translucent stroke. Join geometry is not optional there: butt-capped segments leave a notch at every interior vertex that something has to fill. `stroke-linejoin` is deliberately NOT shipped. SVG emits it and the rasterizer implements all three joins, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all — an attempt at a second instanced pass over interior vertices did not survive verification, so the property would have been honored by two renderers out of three. That is precisely the failure the mark style subset exists to prevent, so it waits for the client. The default-join divergence it leaves behind (rasterizer fills the vertex, WebGL leaves the notch) predates this change and is now written down rather than left to be discovered. Caps are verified end to end, not just at the seam: a Rust unit test on coverage past the endpoint, a Python test on rasterized ink, and three Chromium screenshots that hash differently per cap. Absent keys stay off the wire, so specs built before this change are unchanged byte for byte. ABI_VERSION 41 -> 42: OP_STROKE and OP_SMOOTH_STROKE carry two more bytes. --- CHANGELOG.md | 18 ++ docs/styling/customize.md | 14 +- docs/styling/mark-styles.md | 38 ++- js/src/40_gl.ts | 56 +++- js/src/50_chartview.ts | 10 +- python/xy/_native.py | 2 +- python/xy/_raster.py | 19 +- python/xy/_svg.py | 24 +- python/xy/marks.py | 18 +- python/xy/styles.py | 38 ++- spec/api/styling.md | 49 +++- src/lib.rs | 2 +- src/raster.rs | 423 ++++++++++++++++++++++++++- tests/test_css_mark_styles.py | 118 ++++++++ tests/test_static_client_security.py | 4 +- 15 files changed, 803 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b64223e4..9bc3ea4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ in the README). ## [Unreleased] ### Added +- Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the + line family, with the standard SVG semantics: it shapes the polyline's two + ends and each dash end. Only the line family takes it, because it describes + stroked open-path geometry. XY's default is `round`, not CSS's `butt`, so + existing specs are byte-identical. `stroke-linejoin` is deliberately not + offered yet — the SVG and native-raster writers implement it but the WebGL + client has no join geometry, and a property two renderers honor and one + ignores is exactly what the mark style subset exists to prevent. +- Mark `style=` accepts **`marker-shape`** on `scatter`, the CSS spelling of the + existing `symbol=` argument. Both resolve to the same trace-style value, so + the two spellings build identical specs. - `colormap=` accepts a **custom ramp** built from your own colors, not only one of the twenty built-in names: a sequence of 2–256 CSS colors, `(position, color)` pairs, or a CSS `linear-gradient(...)`. Every form resolves once, in @@ -37,6 +48,13 @@ in the README). that don't use them are byte-identical. ### Fixed +- The three mark renderers disagreed about line caps and never said so: the + native rasterizer capped round, the WebGL client capped butt with a + half-pixel bleed, and the SVG writer hardcoded `round` on line paths while + the area outline silently inherited SVG's `butt` — which the PDF exporter + then read back as `butt` too. All three now draw XY's documented `round` + default, and the SVG writer names both cap and join on every stroked path + instead of letting the format's defaults decide. - The colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis while the marks beside it painted the ramp correctly. diff --git a/docs/styling/customize.md b/docs/styling/customize.md index dfc444a2..6d65163d 100644 --- a/docs/styling/customize.md +++ b/docs/styling/customize.md @@ -95,7 +95,19 @@ def customize_mark_paint_preview(): Use `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, and `opacity` only on mark families that support them. Lines use stroke properties; areas, points, bars, and columns also support fill properties. Bar-like marks -add `border-radius`, while line-like marks add `stroke-dasharray`. +add `border-radius`; line-like marks add `stroke-dasharray` and +`stroke-linecap`; and `scatter` adds `marker-shape`. + +`stroke-linecap` (`butt`, `round`, `square`) carries its standard SVG meaning, +and XY defaults it to `round` rather than to the CSS initial value — the native +rasterizer has always drawn round caps and it is the reference for static +export. `marker-shape` is the CSS spelling of `symbol=` and takes any of the 17 +built-in marker names. + +~~~python +xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) +xy.scatter(x, y, size=12, style={"marker-shape": "diamond"}) +~~~ ## Axes, grid, and ticks diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index 0750e5ea..4f9f97ec 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -14,9 +14,9 @@ renderer cannot silently ignore a declaration that another honors. | Mark family | Supported properties in `style=` | | --- | --- | -| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `opacity` | | `area`, `error_band` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; `area` also supports `stroke-dasharray` | -| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | +| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` | | `histogram`, `bar`, `column` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` | | `segments`, `errorbar`, `contour`, `stem` | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | `box`, `violin` | `fill`, `fill-opacity`, `opacity` | @@ -58,6 +58,40 @@ surfaces set the same rendered property. Inside `style`, use `stroke` for line-like geometry and `fill` for filled geometry; `color` is deliberately not a CSS paint alias there. +## Stroke geometry: line caps + +`stroke-linecap` (`butt`, `round`, `square`) shapes the two ends of a polyline +and each dash end. It is polyline geometry, so only the line family accepts it — +a `bar` or a `scatter` raises rather than accepting a declaration no renderer +can draw. + +XY defaults to `round`, **not** to the CSS initial value `butt`: the native +rasterizer has always drawn round caps and it is the reference for static +export. Set the property to opt into the CSS initial value. + +~~~python +xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) +~~~ + +`stroke-linejoin` is **not** available yet. The SVG and native-raster writers +both implement it, but the browser client draws polylines as one quad per +segment with no join geometry, so honoring it in two renderers out of three +would break the rule that every accepted declaration is drawn everywhere. See +the capability matrix for the current state of that row. + +## Marker shape + +`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`, +`square`, `diamond`, `triangle`, `cross`, `hexagon`, `pentagon`, `star`, +`triangle_down`, `triangle_left`, `triangle_right`, `x`, `point`, `pixel`, +`thin_diamond`, `plus_line`, `x_line` — and is the CSS spelling of the existing +`symbol=` argument. It is an XY vocabulary name rather than a standard CSS +property: CSS has no shape keyword for a non-DOM point mark. + +~~~python +xy.scatter(x, y, size=12, style={"marker-shape": "diamond", "fill": "#22c55e"}) +~~~ + ## Combine mark styles This example combines the main paint paths in one chart: a gradient area, a diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 782e7f16..719076eb 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -469,16 +469,21 @@ void main() { outColor = vec4(rgb * u_opacity, u_opacity); }`; +// Polylines: one instanced quad per segment. `u_cap` is the compiled +// stroke-linecap value (see LINE_CAP_MODES); XY defaults it to round, which is +// what the native rasterizer's clamped segment distance field draws +// (src/raster.rs), so all three renderers agree. export const LINE_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float a_prevx; in float a_prevy; in float a_prevx1; in float a_prevy1; uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; uniform int u_colorMode; +uniform int u_cap; uniform int u_capSegments; uniform float u_transitionProgress; uniform int u_transitionActive; uniform float u_revealProgress; uniform float u_revealSegments; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; in float a_len0; in float a_len1; -out float v_off; out float v_dash; +out float v_off; out float v_dash; out vec2 v_cap; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -498,45 +503,74 @@ void main() { vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; float half_w = u_width * 0.5 + 0.5; - vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; + // Along-segment coordinate in px. Interior joints keep the 0.5px overlap that + // hides the seam between consecutive quads; the polyline's two outer ends + // instead grow by half_w when the cap reaches past them (round semicircle, + // square extension), giving LINE_FS room to shape it. + bool first = gl_InstanceID == 0; + bool last = gl_InstanceID == u_capSegments - 1; + float capExt = u_cap == 0 ? 0.5 : half_w; + float t = mix(first ? -capExt : -0.5, len + (last ? capExt : 0.5), c.x); + vec2 pos = pix0 + dir * t + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; + // Signed px overrun past the polyline's start/end, far negative where that + // end is an interior joint instead; LINE_FS takes the max of the two. + v_cap = vec2(first ? -t : -1e4, last ? t - len : -1e4); // Cumulative screen-space arc length at this fragment (device px), fed from // CPU-computed per-vertex lengths so dashes stay continuous across segments - // and constant on screen through zoom. - v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x); + // and constant on screen through zoom. Driven off t rather than c.x so the + // overhang carries the pattern on at 1px of arc per px of screen instead of + // stretching the segment's slice of it over the widened quad. + float dashEnd = mix(a_len0, a_len1, reveal); + v_dash = a_len0 + t * (len > 1e-3 ? (dashEnd - a_len0) / len : 1.0); }`; export const LINE_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; +uniform vec4 u_color; uniform float u_width; uniform int u_cap; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_dash; +in float v_off; in float v_dash; in vec2 v_cap; out vec4 outColor; void main() { float half_w = u_width * 0.5; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + // How far past the nearest end of painted stroke this fragment lies, along + // the path. Two kinds of end contribute and the cap shapes both: the + // polyline's own ends (v_cap) and, when dashed, the ends of the dash run — + // the distance to the nearer dash boundary is exactly the overrun a cap has + // to bridge, so the greater of the two is the one that governs. + float axial = max(v_cap.x, v_cap.y); if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; - float on = 0.0; + float sd = 0.0; for (int i = 0; i < 8; i++) { if (i >= u_dashCount) break; float next = acc + u_dashArr[i]; if (m < next) { - // 0.6px feather at each dash start/end so edges aren't aliased. float d = min(m - acc, next - m); - on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0); + sd = (i % 2 == 0) ? d : -d; // + inside an "on" run, - inside a gap break; } acc = next; } - alpha *= on; + axial = max(axial, -sd); } + // round = semicircle of radius half_w about the end, so the distance field + // picks up the overrun; butt = flush; square = flush pushed out by half_w. + float radial = u_cap == 1 ? length(vec2(max(axial, 0.0), v_off)) : abs(v_off); + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, radial)) * u_color.a; + if (u_cap == 0) alpha *= 1.0 - smoothstep(-0.5, 0.5, axial); + else if (u_cap == 2) alpha *= 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, axial); if (alpha <= 0.001) discard; outColor = vec4(u_color.rgb * alpha, alpha); }`; +// Wire spellings of stroke-linecap → the u_cap int LINE_FS switches on. A +// trace omits the key at XY's default (round), so an unset style must resolve +// to `round`, not to the CSS initial value `butt`. +export const LINE_CAP_MODES = { butt: 0, round: 1, square: 2 }; + // Segment marks (errorbar/stem/box whiskers/contour isolines): independent // endpoint pairs with per-column axis metas and an optional per-segment LUT // color. A separate program keeps the polyline path (LINE_VS/LINE_FS) free of diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 420ae75f..68f2a40d 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,7 +2,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; -import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -4031,7 +4031,12 @@ export class ChartView { const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1)); gl.uniform1f(u("u_revealProgress"), reveal); gl.uniform1f(u("u_revealSegments"), g.n - 1); - gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr); + const lineWidth = (width ?? g.trace.style.width ?? 1.5) * this.dpr; + gl.uniform1f(u("u_width"), lineWidth); + // Absent cap/join keys mean XY's default, which is round for both — the + // trace only carries them when they differ from it (marks._stroke_geometry). + const cap = LINE_CAP_MODES[g.trace.style.linecap] ?? LINE_CAP_MODES.round; + gl.uniform1i(u("u_cap"), cap); const [r, gg, b, a] = color || g.color; const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1) * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity); @@ -4060,6 +4065,7 @@ export class ChartView { } ); const segments = Math.max(0, Math.min(g.n - 1, Math.ceil((g.n - 1) * reveal))); + gl.uniform1i(u("u_capSegments"), segments); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, segments); } diff --git a/python/xy/_native.py b/python/xy/_native.py index b9c07c41..c2cc5e31 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 41 +ABI_VERSION = 42 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 5733b54a..abf8e9e8 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -80,6 +80,11 @@ # (right-margin titles, matplotlib rotation=270). _TEXT_ROT_CCW = 0x80 _TEXT_ROT_CW = 0x40 +# stroke-linecap / stroke-linejoin — must match CAP_*/JOIN_* in src/raster.rs. +# XY's default is round for both, which is the geometry the rasterizer's +# capsule distance field has always drawn. +_CAP_CODES = {"butt": 0, "round": 1, "square": 2} +_JOIN_CODES = {"miter": 0, "round": 1, "bevel": 2} _SYMBOLS = { "circle": 0, "square": 1, @@ -223,6 +228,8 @@ def stroke( color: tuple[int, ...], closed: bool = False, dash: Sequence[float] | None = None, + cap: str = "round", + join: str = "round", ) -> None: if len(pts) < 2 or width <= 0: return @@ -242,6 +249,8 @@ def stroke( self._u32(len(dash)) for d in dash: self._f(d) + self.buf.append(_CAP_CODES[cap]) + self.buf.append(_JOIN_CODES[join]) def point( self, @@ -489,6 +498,8 @@ def smooth_stroke( width: float, color: tuple[int, ...], dash: Sequence[float] | None = None, + cap: str = "round", + join: str = "round", ) -> None: """Native monotone-Hermite flattening + stroke for affine axes.""" n = len(xv) @@ -515,6 +526,8 @@ def smooth_stroke( self._u32(len(dash)) for value in dash: self._f(value) + self.buf.append(_CAP_CODES[cap]) + self.buf.append(_JOIN_CODES[join]) def image( self, @@ -1052,11 +1065,13 @@ def _emit_line( xv, yv = _step_arrays(xv, yv, style["step"]) c = _rgba(style.get("color"), color, _stroke_opacity(style)) width = float(style.get("width", 1.5)) + cap = str(style.get("linecap", "round")) + join = str(style.get("linejoin", "round")) if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine: - cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash")) + cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap, join=join) else: pts = _scene.curve_points(xv, yv, sx, sy, False) - cmd.stroke(pts, width, c, dash=style.get("dash")) + cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap, join=join) def _annotation_point( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 1227afa2..35fbdb36 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1076,6 +1076,22 @@ def _star_path(cx: float, cy: float, r: float, points: int, inner: float, start_ return f' str: + """Polyline stroke geometry, always written out rather than inherited. + + SVG's initial values are `butt`/`miter`; XY's are `round`/`round`, and the + trace only carries a key when it differs (`marks._stroke_geometry`). Naming + both attributes on every stroked path is what keeps the SVG agreeing with + the native rasterizer — and with `_pdf`, which reads them straight back out + of this markup and would otherwise fall through to SVG's defaults. + """ + cap = style.get("linecap", "round") + attrs = f' stroke-linecap="{escape(str(cap))}"' + if join: + attrs = f' stroke-linejoin="{escape(str(style.get("linejoin", "round")))}"' + attrs + return attrs + + def _dash_attr(style: dict[str, Any]) -> str: dash = style.get("dash") if not dash: @@ -1591,7 +1607,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: op = _stroke_opacity(style) return ( f'stroke="{escape(color)}" stroke-width="{_num(w)}" fill="none" ' - f'stroke-linejoin="round" stroke-linecap="round"' + + _cap_join_attrs(style) + (f' stroke-opacity="{_num(op)}"' if op < 1 else "") + _dash_attr(style) ) @@ -1640,7 +1656,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str: outline_path = joined if style.get("stroke_perimeter") else top_path marks.append( f'" diff --git a/python/xy/marks.py b/python/xy/marks.py index e77331f7..6c0c5a13 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -12,7 +12,7 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Optional, Union import numpy as np @@ -93,6 +93,19 @@ def _direct_symbols(value: Any, n: int, style_channels: dict[str, channels.Style return "circle" +def _stroke_geometry(css: Mapping[str, Any]) -> dict[str, str]: + """The polyline cap key from compiled CSS, omitted at its default. + + Every renderer already draws XY's default `round`, so a spec that never + asks for another cap stays byte-identical to one built before the property + existed. + """ + value = css.get("linecap") + if value is None or value == styles.DEFAULT_LINE_CAP: + return {} + return {"linecap": str(value)} + + def _stroke_channel( value: Any, n: int, label: str ) -> tuple[Optional[str], Optional[channels.ColorChannel]]: @@ -816,6 +829,7 @@ def line( yc = self.store.ingest(yc.values[order]) style: dict[str, Any] = {"color": color, "width": width, "opacity": opacity} style.update(styles._opacity_channels(css)) + style.update(_stroke_geometry(css)) if curve != "linear": style["curve"] = curve if dash_spec is not None: @@ -1149,6 +1163,7 @@ def step( ) self.traces[-1].style["step"] = where self.traces[-1].style.update(styles._opacity_channels(css)) + self.traces[-1].style.update(_stroke_geometry(css)) return self @@ -1373,6 +1388,7 @@ def scatter( opacity = css.get("opacity", opacity) stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) + symbol = css.get("symbol", symbol) name = self._optional_text(name, "scatter name") zoom_size_factor = self._nonnegative_scalar(zoom_size_factor, "scatter zoom_size_factor") if zoom_size_factor == 0.0: diff --git a/python/xy/styles.py b/python/xy/styles.py index c24a1515..be59d2d9 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -46,6 +46,23 @@ _AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"}) _AXIS_DIRECTIONS = frozenset({"in", "out", "inout"}) +# Polyline stroke geometry. All three mark renderers (WebGL, SVG, native +# rasterizer) draw these caps identically; XY defaults to `round` rather than +# the CSS initial value `butt` because the native rasterizer is the reference +# for static export and has always drawn round. Set the property to opt into +# the CSS initial value. +# +# `stroke-linejoin` is deliberately absent. SVG and the native rasterizer both +# implement all three joins, but the WebGL client draws polylines as one +# instanced quad per segment with no join geometry at all, so a `miter` there +# would render as the overlap the segments already produce. Accepting a +# declaration one renderer silently ignores is exactly what this module exists +# to prevent, so the property waits for the client. The gap — including the +# joins the three renderers already disagree on by default — is a row in +# `xy.styling.capabilities`. +LINE_CAPS = frozenset({"butt", "round", "square"}) +DEFAULT_LINE_CAP = "round" + _MARK_KINDS = tuple( sorted( _LINE_KINDS @@ -170,6 +187,12 @@ def _dasharray(value: StyleValue, label: str) -> list[float] | None: return lengths +def _keyword(value: StyleValue, allowed: frozenset[str], label: str) -> str: + if not isinstance(value, str) or value not in allowed: + raise ValueError(f"{label} must be one of {sorted(allowed)}") + return value + + def _paint(value: StyleValue, label: str) -> str: return _validate.css_color(value, label) @@ -195,7 +218,13 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: raise ValueError(f"unknown mark kind {kind!r}; expected one of {_MARK_KINDS}") props = {"opacity"} if kind in _LINE_KINDS: - props |= {"stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} + props |= { + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + } elif kind in _SIMPLE_STROKE_KINDS: props |= {"stroke", "stroke-width", "stroke-opacity"} elif kind in _AREA_KINDS: @@ -216,6 +245,7 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: "stroke", "stroke-width", "stroke-opacity", + "marker-shape", } elif kind in _RECT_KINDS: props |= { @@ -302,6 +332,10 @@ def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> di _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen) elif prop == "stroke-dasharray": _set(out, "dash", _dasharray(raw, f"{label}['stroke-dasharray']"), prop, seen) + elif prop == "stroke-linecap": + _set(out, "linecap", _keyword(raw, LINE_CAPS, f"{label}['stroke-linecap']"), prop, seen) + elif prop == "marker-shape": + _set(out, "symbol", _validate.point_symbol(raw, f"{label}['marker-shape']"), prop, seen) elif prop == "border-radius": _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen) return out @@ -371,6 +405,8 @@ def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]: __all__ = [ + "DEFAULT_LINE_CAP", + "LINE_CAPS", "StyleMapping", "StyleValue", "compile_axis_style", diff --git a/spec/api/styling.md b/spec/api/styling.md index 0f33fc28..e9ff75e1 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -83,9 +83,9 @@ xy.bar( | Mark family | Supported CSS properties | | --- | --- | -| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `opacity` | | area, error band | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; area also supports `stroke-dasharray` | -| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | +| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` | | histogram, bar, column | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` | | segments, error bars, contour, stem | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | box, violin | `fill`, `fill-opacity`, `opacity` | @@ -103,6 +103,51 @@ A mark's `class_name` is adapter-only trace metadata. It does not create a DOM node and is not interpreted as a paint selector by the shipped browser, Reflex, SVG, or native renderers. +### Polyline stroke geometry + +`stroke-linecap` (`butt` | `round` | `square`) carries its standard SVG +semantics: it shapes the two ends of an open polyline and each dash end. It is +accepted **only** by the line family, because it describes stroked open-path +geometry; every other mark rejects it at build time rather than accepting a +declaration no renderer would draw. + +XY's default is `round`, deliberately not the CSS initial value `butt`. Before +this vocabulary existed the three renderers silently disagreed — the native +rasterizer capped round from its clamped segment distance field +(`src/raster.rs`), the WebGL client capped butt with a half-pixel bleed, and +the SVG writer hardcoded `round` on line paths while the area outline inherited +SVG's `butt`. Round is now the contract in all three, because the native +rasterizer is the reference for static export. + +`styles.DEFAULT_LINE_CAP` names that default and `marks._stroke_geometry` omits +a key that equals it, so a spec that never asks for another cap stays +byte-identical to one built before the change. + +**`stroke-linejoin` is not offered yet, and the reason is a renderer, not an +oversight.** SVG emits the attribute and the native rasterizer implements all +three joins (`join_shape` in `src/raster.rs`, exercised whenever a non-round cap +forces the shaped path). The WebGL client draws a polyline as one instanced +quad per segment with *no join geometry whatsoever* — adjacent quads simply +overlap by half a pixel — so it has no way to distinguish a miter from a bevel. +Shipping the property would mean two renderers honoring it and one ignoring it, +which is the failure this module exists to prevent. It waits for the client. + +That leaves a real cross-renderer difference in the *default*: the rasterizer +fills interior vertices with a round join and the WebGL client leaves the notch +two overlapping quads produce. That predates this vocabulary, is visible only on +wide strokes at sharp angles, and is recorded as a row in +`xy.styling.capabilities` rather than left for a reader to discover. + +### Marker shape + +`marker-shape` selects one of the 17 renderer-backed scatter symbols and is the +CSS spelling of the existing `symbol=` argument — both resolve to the same +`symbol` trace-style value, so the two spellings produce identical specs. It is +an **XY vocabulary name, not a standard CSS property**: CSS has no shape keyword +for a non-DOM point mark, and the alternative (a `-xy-` vendor prefix) would +force an unusable `_xy_marker_shape` Python alias. The distinction is recorded +per property rather than encoded in the name. + ### Reflex integration boundary Reflex owns reactive `Var` values, conditions, application state, event diff --git a/src/lib.rs b/src/lib.rs index 0d893374..2dd2c048 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,7 +82,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 41; +pub const ABI_VERSION: u32 = 42; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] diff --git a/src/raster.rs b/src/raster.rs index 5da85840..7c308b6d 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -427,10 +427,162 @@ fn fill_poly(cv: &mut Canvas, pts: &[(f32, f32)], mut color_at: impl FnMut(f32, } } -// ---- stroke (distance field, round caps/joins) ------------------------------ +// ---- stroke (distance field, round caps/joins by default) ------------------- type StrokeSegment = ((f32, f32), (f32, f32)); +// stroke-linecap / stroke-linejoin, in the wire order python/xy/styles.py +// compiles: butt/round/square and miter/round/bevel. XY's default is round for +// both, which is what the clamped segment distance field below has always +// drawn, so `stroke` stays the fast path and byte-for-byte unchanged; anything +// else routes through `stroke_shaped`. +pub const CAP_BUTT: u8 = 0; +pub const CAP_ROUND: u8 = 1; +pub const CAP_SQUARE: u8 = 2; +pub const JOIN_MITER: u8 = 0; +pub const JOIN_ROUND: u8 = 1; +pub const JOIN_BEVEL: u8 = 2; + +/// SVG's default miter limit: past it a miter degrades to a bevel, so a near +/// reversal in the data cannot grow a spike that is not in the data. +const MITER_LIMIT: f32 = 4.0; + +#[inline] +fn cov_from_sd(sd: f32) -> f32 { + // Same 1px ramp `seg_coverage` uses, expressed on a signed distance so the + // box, disc, and wedge primitives below all antialias identically. + (0.5 - sd).clamp(0.0, 1.0) +} + +/// Signed distance to an oriented box of half-width `hw` around segment `a`-`b` +/// — a capsule with both ends cut flush, i.e. a butt cap. +fn box_sd(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return f32::INFINITY; + } + let (ux, uy) = (dx / len, dy / len); + let (mx, my) = ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5); + let (rx, ry) = (p.0 - mx, p.1 - my); + let along = (rx * ux + ry * uy).abs() - len * 0.5; + let across = (rx * -uy + ry * ux).abs() - hw; + let outside = (along.max(0.0).powi(2) + across.max(0.0).powi(2)).sqrt(); + outside + along.max(across).min(0.0) +} + +/// Signed distance to the intersection of the half-planes bounded by each +/// directed edge of a convex polygon wound consistently. Exact on the edges and +/// conservative at the corners, which is all a 1px ramp needs — the corners of +/// a bevel or miter sit under the segment boxes anyway. +fn convex_sd(p: (f32, f32), poly: &[(f32, f32)]) -> f32 { + let mut sd = f32::NEG_INFINITY; + for i in 0..poly.len() { + let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); + let (ex, ey) = (b.0 - a.0, b.1 - a.1); + let len = (ex * ex + ey * ey).sqrt(); + if len <= 1e-6 { + continue; + } + // Outward normal for clockwise winding in this frame; `join_shape` + // winds every polygon it builds the same way. + let (nx, ny) = (ey / len, -ex / len); + sd = sd.max((p.0 - a.0) * nx + (p.1 - a.1) * ny); + } + sd +} + +/// The filler for the notch two segment boxes leave on the outside of a turn. +/// Returns `None` for a straight or degenerate joint, which needs nothing. +fn join_shape( + prev: (f32, f32), + at: (f32, f32), + next: (f32, f32), + hw: f32, + join: u8, +) -> Option> { + let (d0x, d0y) = (at.0 - prev.0, at.1 - prev.1); + let (d1x, d1y) = (next.0 - at.0, next.1 - at.1); + let l0 = (d0x * d0x + d0y * d0y).sqrt(); + let l1 = (d1x * d1x + d1y * d1y).sqrt(); + if l0 <= 1e-6 || l1 <= 1e-6 { + return None; + } + let (d0x, d0y) = (d0x / l0, d0y / l0); + let (d1x, d1y) = (d1x / l1, d1y / l1); + let cross = d0x * d1y - d0y * d1x; + if cross.abs() <= 1e-6 { + return None; // collinear: the boxes already meet flush + } + // Outward side is the one the path turns away from. + let side = if cross > 0.0 { -1.0 } else { 1.0 }; + let n0 = (side * -d0y, side * d0x); + let n1 = (side * -d1y, side * d1x); + let c0 = (at.0 + n0.0 * hw, at.1 + n0.1 * hw); + let c1 = (at.0 + n1.0 * hw, at.1 + n1.1 * hw); + // Wind so `convex_sd`'s outward normals point away from the interior. + let wound = |a: (f32, f32), b: (f32, f32), c: (f32, f32)| -> bool { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) > 0.0 + }; + let mut poly = vec![at, c0, c1]; + if join == JOIN_MITER { + let (sx, sy) = (n0.0 + n1.0, n0.1 + n1.1); + let slen = (sx * sx + sy * sy).sqrt(); + if slen > 1e-4 { + let (mx, my) = (sx / slen, sy / slen); + let mcos = mx * n0.0 + my * n0.1; + if mcos >= 1.0 / MITER_LIMIT { + let reach = hw / mcos; + poly = vec![at, c0, (at.0 + mx * reach, at.1 + my * reach), c1]; + } + } + } + if !wound(poly[0], poly[1], poly[2]) { + poly.reverse(); + } + Some(poly) +} + +/// One coverage primitive of a shaped stroke, in the order they are painted. +enum StrokePiece { + Box(StrokeSegment), + Disc((f32, f32)), + Convex(Vec<(f32, f32)>), +} + +impl StrokePiece { + fn bounds(&self, hw: f32) -> ((f32, f32), (f32, f32)) { + let pad = hw + 1.0; + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let mut extend = |p: (f32, f32)| { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + }; + match self { + StrokePiece::Box((a, b)) => { + extend(*a); + extend(*b); + } + StrokePiece::Disc(c) => extend(*c), + StrokePiece::Convex(poly) => poly.iter().copied().for_each(&mut extend), + } + ((x0 - pad, y0 - pad), (x1 + pad, y1 + pad)) + } + + fn coverage(&self, p: (f32, f32), hw: f32) -> f32 { + match self { + StrokePiece::Box(( a, b)) => cov_from_sd(box_sd(p, *a, *b, hw)), + StrokePiece::Disc(c) => { + cov_from_sd(((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt() - hw) + } + StrokePiece::Convex(poly) => cov_from_sd(convex_sd(p, poly)), + } + } +} + #[inline] fn seg_dist2(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { let (px, py) = p; @@ -474,6 +626,184 @@ fn stroke( stroke_with_threads(cv, pts, width, rgba, closed, dash, None); } +/// Stroke honoring an explicit cap and join. Round/round is XY's default and +/// is exactly what the capsule field above draws, so it delegates and the +/// common path keeps its banding, its scratch-buffer reuse, and its bytes. +fn stroke_shaped( + cv: &mut Canvas, + pts: &[(f32, f32)], + width: f32, + rgba: [f32; 4], + closed: bool, + dash: &[f32], + cap: u8, + join: u8, +) { + if (cap == CAP_ROUND && join == JOIN_ROUND) || pts.len() < 2 || width <= 0.0 { + stroke(cv, pts, width, rgba, closed, dash); + return; + } + let hw = width * 0.5; + let n = pts.len(); + let last = if closed { n } else { n - 1 }; + let raw: Vec = (0..last).map(|i| (pts[i], pts[(i + 1) % n])).collect(); + + // Each run is a maximal stretch of painted stroke: the whole polyline when + // undashed, one per "on" interval otherwise. Caps shape a run's two ends; + // joins shape the vertices inside it. SVG caps every dash the same way, so + // a dashed line gets one pair of caps per run rather than one per line. + let mut runs: Vec> = Vec::new(); + if dash.is_empty() { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let total: f32 = dash.iter().sum(); + if total <= 0.0 { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let (mut di, mut drem, mut on) = (0usize, dash[0], true); + let mut current: Vec<(f32, f32)> = Vec::new(); + for (a, b) in &raw { + let (mut ax, mut ay) = *a; + let seglen = ((b.0 - ax).powi(2) + (b.1 - ay).powi(2)).sqrt(); + if seglen <= 1e-9 { + continue; + } + let (ux, uy) = ((b.0 - a.0) / seglen, (b.1 - a.1) / seglen); + let mut remain = seglen; + while remain > 1e-6 { + let step = drem.min(remain); + let next = (ax + ux * step, ay + uy * step); + if on { + if current.is_empty() { + current.push((ax, ay)); + } + current.push(next); + } + ax = next.0; + ay = next.1; + remain -= step; + drem -= step; + if drem <= 1e-6 { + di = (di + 1) % dash.len(); + drem = dash[di]; + on = !on; + if !on && !current.is_empty() { + runs.push(std::mem::take(&mut current)); + } + } + } + } + if !current.is_empty() { + runs.push(current); + } + } + } + + let mut pieces: Vec = Vec::new(); + for run in &runs { + if run.len() < 2 { + continue; + } + let mut ends = run.clone(); + if cap == CAP_SQUARE { + // A square cap is a butt cap on a segment pushed out by hw, which + // is the whole difference between the two. + let extend = |from: (f32, f32), to: (f32, f32)| -> (f32, f32) { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return to; + } + (to.0 + dx / len * hw, to.1 + dy / len * hw) + }; + let head = extend(run[1], run[0]); + let tail = extend(run[run.len() - 2], run[run.len() - 1]); + ends[0] = head; + let end = ends.len() - 1; + ends[end] = tail; + } + for pair in ends.windows(2) { + pieces.push(StrokePiece::Box((pair[0], pair[1]))); + } + for i in 1..run.len() - 1 { + match join { + JOIN_ROUND => pieces.push(StrokePiece::Disc(run[i])), + _ => { + if let Some(poly) = join_shape(run[i - 1], run[i], run[i + 1], hw, join) { + pieces.push(StrokePiece::Convex(poly)); + } + } + } + } + if closed && runs.len() == 1 && run.len() > 2 { + let joint = match join { + JOIN_ROUND => Some(StrokePiece::Disc(run[0])), + _ => join_shape(run[run.len() - 2], run[0], run[1], hw, join) + .map(StrokePiece::Convex), + }; + pieces.extend(joint); + } + } + paint_stroke_pieces(cv, &pieces, hw, rgba); +} + +/// Max-combine every piece's coverage into one scratch buffer, then composite +/// each touched pixel once — the same contract `stroke_with_threads` keeps, so +/// overlapping joins never double-darken a translucent stroke. +fn paint_stroke_pieces(cv: &mut Canvas, pieces: &[StrokePiece], hw: f32, rgba: [f32; 4]) { + if pieces.is_empty() { + return; + } + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + x0 = x0.min(px0); + y0 = y0.min(py0); + x1 = x1.max(px1); + y1 = y1.max(py1); + } + let (bx0, by0, bx1, by1) = cv.bbox(x0, y0, x1, y1); + if bx1 <= bx0 || by1 <= by0 { + return; + } + let (sw, sh) = (bx1 - bx0, by1 - by0); + let mut scratch = vec![0u8; sw * sh]; + let mut touched = Vec::::with_capacity(pieces.len().saturating_mul(8)); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + let sx0 = (px0.floor().max(bx0 as f32) as usize).max(bx0); + let sy0 = (py0.floor().max(by0 as f32) as usize).max(by0); + let sx1 = (px1.ceil().max(0.0) as usize).min(bx1); + let sy1 = (py1.ceil().max(0.0) as usize).min(by1); + for y in sy0..sy1 { + for x in sx0..sx1 { + let c = piece.coverage((x as f32 + 0.5, y as f32 + 0.5), hw); + if c <= 0.0 { + continue; + } + let index = (y - by0) * sw + (x - bx0); + let slot = &mut scratch[index]; + if *slot == 0 { + touched.push(index); + } + let coverage = to_u8(c); + if coverage > *slot { + *slot = coverage; + } + } + } + } + for index in touched { + let (row, col) = (index / sw, index % sw); + cv.blend(bx0 + col, by0 + row, rgba, scratch[index] as f32 / 255.0); + } +} + fn stroke_with_threads( cv: &mut Canvas, pts: &[(f32, f32)], @@ -1940,7 +2270,9 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - stroke(&mut cv, &pts, width, c, closed, &dash); + let cap = r.u8()?; + let join = r.u8()?; + stroke_shaped(&mut cv, &pts, width, c, closed, &dash, cap, join); } OP_POINT => { let (cx, cy, rr) = (r.f32()?, r.f32()?, r.f32()?); @@ -2385,8 +2717,10 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } + let cap = r.u8()?; + let join = r.u8()?; let points = smooth_points(xs, ys, n, x_scale, y_scale); - stroke(&mut cv, &points, width, color, false, &dash); + stroke_shaped(&mut cv, &points, width, color, false, &dash, cap, join); } _ => return None, } @@ -2563,12 +2897,94 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); // not closed cmd.extend(u32le(0)); // no dash + cmd.extend([CAP_ROUND, JOIN_ROUND]); let mut out = vec![0u8; 10 * 10 * 4]; assert!(rasterize_into(&cmd, 10, 10, &mut out)); assert!(px(&out, 10, 5, 5)[3] > 200); // on the line assert_eq!(px(&out, 10, 5, 0)[3], 0); // far from it } + /// stroke-linecap changes what is painted past the endpoint, and round — + /// XY's default — must keep drawing exactly what it drew before caps + /// existed, because it is the geometry every committed PNG expectation + /// was rendered with. + #[test] + fn stroke_caps_shape_the_ends() { + let ink = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); // width + cmd.extend([0, 0, 0, 255]); + cmd.push(0); // not closed + cmd.extend(u32le(0)); // no dash + cmd.extend([cap, JOIN_ROUND]); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + (0..40 * 40).filter(|i| out[i * 4 + 3] > 128).count() + }; + // A square cap adds a half-width block at each end; a round cap adds a + // semicircle, which is less; a butt cap adds nothing. + assert!(ink(CAP_BUTT) < ink(CAP_ROUND)); + assert!(ink(CAP_ROUND) < ink(CAP_SQUARE)); + + // Butt clips at the end plane: the pixel a half-width past the last + // point is painted for square, bare for butt. + let probe = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); + cmd.extend([0, 0, 0, 255]); + cmd.push(0); + cmd.extend(u32le(0)); + cmd.extend([cap, JOIN_ROUND]); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + px(&out, 40, 32, 20)[3] + }; + assert_eq!(probe(CAP_BUTT), 0); + assert!(probe(CAP_SQUARE) > 200); + } + + /// stroke-linejoin fills the notch on the outside of a turn. A miter + /// reaches furthest, a bevel cuts the corner off, and round is the disc + /// the capsule field already drew. + #[test] + fn stroke_joins_shape_the_corner() { + let corner = |join: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(3)); + for (x, y) in [(10.0f32, 10.0f32), (30.0, 10.0), (30.0, 30.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(10.0)); // width + cmd.extend([0, 0, 0, 255]); + cmd.push(0); + cmd.extend(u32le(0)); + cmd.extend([CAP_BUTT, join]); + let mut out = vec![0u8; 50 * 50 * 4]; + assert!(rasterize_into(&cmd, 50, 50, &mut out)); + // The outside of this right-angle turn is up and to the right of + // the vertex; the miter apex is the only shape that reaches it. + (px(&out, 50, 34, 6)[3], (0..50 * 50).filter(|i| out[i * 4 + 3] > 128).count()) + }; + let (miter_apex, miter_ink) = corner(JOIN_MITER); + let (bevel_apex, bevel_ink) = corner(JOIN_BEVEL); + let (_, round_ink) = corner(JOIN_ROUND); + + assert!(miter_apex > 200, "a miter fills its apex"); + assert_eq!(bevel_apex, 0, "a bevel cuts the apex off"); + assert!(bevel_ink < round_ink && round_ink < miter_ink); + } + #[test] fn prepared_segment_path_matches_float_coverage_reference() { let cases = [ @@ -3091,6 +3507,7 @@ mod tests { expanded.extend(stroke_color); expanded.push(1); // closed expanded.extend(u32le(0)); // no dash + expanded.extend([CAP_ROUND, JOIN_ROUND]); } for opaque in [false, true] { diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 156d1e35..202c5ea3 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -307,3 +307,121 @@ def test_faceting_preserves_concrete_css_style() -> None: grid = chart.figure() assert all(figure.traces[0].style["color"] == "#7c3aed" for figure in grid.figures) + + +def test_line_cap_compiles_to_the_polyline_contract() -> None: + assert compile_mark_style("line", {"stroke-linecap": "butt"}) == {"linecap": "butt"} + + with pytest.raises(ValueError, match=r"must be one of \['butt', 'round', 'square'\]"): + compile_mark_style("line", {"stroke-linecap": "flat"}) + + +def test_line_cap_is_a_polyline_only_property() -> None: + # A cap is polyline geometry. Marks that are not stroked open paths reject + # it rather than accepting a declaration no renderer would draw. + for kind in ("bar", "scatter", "heatmap", "box", "segments"): + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style(kind, {"stroke-linecap": "butt"}) + + +def test_stroke_linejoin_is_not_offered_until_the_client_draws_joins() -> None: + # SVG and the native rasterizer implement all three joins; the WebGL client + # has no join geometry at all. Until it does, the property is refused + # rather than honored by two renderers out of three. + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"stroke-linejoin": "miter"}) + + +def test_default_cap_stays_off_the_wire() -> None: + # XY's default is round in every renderer, so a spec that asks for it + # explicitly must stay byte-identical to one that never mentions it. + plain = xy.chart(xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0])).figure() + explicit = xy.chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "round"}) + ).figure() + + assert "linecap" not in explicit.traces[0].style + assert plain.build_payload()[0] == explicit.build_payload()[0] + + +def test_cap_rides_the_spec_for_the_line_family() -> None: + for mark in ( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.step(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.ecdf(values=[0.0, 1.0, 2.0], style={"stroke-linecap": "square"}), + ): + spec, _ = xy.chart(mark).figure().build_payload() + assert spec["traces"][0]["style"]["linecap"] == "square" + + +def test_cap_reaches_svg_and_native_renderers() -> None: + fig = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke-width": "9px", "stroke-linecap": "butt"}, + ) + ).figure() + + svg = fig.to_svg() + assert 'stroke-linecap="butt"' in svg + # The SVG writer names the join too, so the PDF exporter (which reads these + # attributes straight back out) cannot fall through to SVG's `miter`. + assert 'stroke-linejoin="round"' in svg + + # The native rasterizer must actually draw a different shape, not just + # accept the keyword: a square cap paints past the endpoint, butt does not. + def _ink(cap: str) -> int: + figure = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke": "#ff0000", "stroke-width": "9px", "stroke-linecap": cap}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 0] > image[:, :, 2])) + + assert _ink("square") > _ink("butt") + + +def test_marker_shape_css_selects_the_scatter_symbol() -> None: + assert compile_mark_style("scatter", {"marker-shape": "diamond"}) == {"symbol": "diamond"} + with pytest.raises(ValueError, match="must be one of"): + compile_mark_style("scatter", {"marker-shape": "blob"}) + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"marker-shape": "diamond"}) + + fig = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=12, + style={"marker-shape": "square", "fill": "#22c55e"}, + ) + ).figure() + spec, blob = fig.build_payload() + assert spec["traces"][0]["style"]["symbol"] == "square" + + # A square marker fills its bounding box; a circle of the same size cannot. + def _ink(shape: str) -> int: + figure = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=24, + style={"marker-shape": shape, "fill": "#22c55e", "opacity": 1}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 1] > image[:, :, 2])) + + assert _ink("square") > _ink("circle") + + +def test_marker_shape_css_loses_to_no_one_but_agrees_with_the_symbol_argument() -> None: + css = xy.chart( + xy.scatter(x=[0.0], y=[1.0], symbol="circle", style={"marker-shape": "triangle"}) + ).figure() + argument = xy.chart(xy.scatter(x=[0.0], y=[1.0], symbol="triangle")).figure() + assert css.build_payload()[0] == argument.build_payload()[0] diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 2bd2f76b..b1dd0e1a 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -803,7 +803,9 @@ def test_client_renders_mark_level_styling() -> None: "xyMarkerSdf(d, u_symbol)", # scatter symbol shapes (circle/square/diamond/triangle/cross) "_pointMarkStyle(", # point stroke + symbol resolution "rgb = mix(rgb, sc.rgb, sc.a);", # selected/unselected recolor (mark_style) - "v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x);", # fractional reveal preserves line dashes + "float dashEnd = mix(a_len0, a_len1, reveal);", # fractional reveal preserves line dashes + "uniform int u_cap; uniform int u_capSegments;", # stroke-linecap on the polyline's ends + "LINE_CAP_MODES", # cap keywords resolve to the shared wire codes "_lineDash(g)", "_resolveMarkFill(", "_setRectStyleUniforms(",