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(",