Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions js/src/40_gl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ void main() {
float coverage = smoothstep(0.0, max(fwidth(edge) * strokeWidth, 1e-5), edge);
// Both stroke sources ship straight alpha; the per-item alpha stack
// applies to scalar strokes as well (parity with static exporters).
vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke;
vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : (u_strokeMode == 2 ? paint : u_stroke);
float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity;
vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha);
outColor = mix(stroke, fill, coverage);
Expand Down Expand Up @@ -849,7 +849,7 @@ void main() {
if (strokeWidth > 0.0) {
// Both stroke sources ship straight alpha; the per-item alpha stack
// applies to scalar strokes as well (parity with static exporters).
vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke;
vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : (u_strokeMode == 2 ? paint : u_stroke);
float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity;
vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha);
float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth);
Expand Down
6 changes: 4 additions & 2 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2417,7 +2417,7 @@ export class ChartView {
// stack in and premultiplies there (uniform and buffer strokes alike).
const sc = g.strokeColor || [0, 0, 0, 0];
gl.uniform4f(u("u_stroke"), sc[0], sc[1], sc[2], sc[3]);
gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0);
gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : (g.strokeMatchFill ? 2 : 0));
gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {}));
this._setGradientUniforms(prog, g.grad);
}
Expand All @@ -2433,6 +2433,7 @@ export class ChartView {
? [Number(cr[0]) || 0, Number(cr[1]) || 0]
: [Number(cr) || 0, Number(cr) || 0];
g.strokeWidth = Number(s.stroke_width) || 0;
g.strokeMatchFill = !!(t.stroke && t.stroke.mode === "match_fill");
const opaque = [g.color[0], g.color[1], g.color[2], 1];
g.strokeColor = s.stroke ? parseColor(this.root, s.stroke, opaque) : opaque;
g.grad = this._resolveMarkFill(s, g.color);
Expand Down Expand Up @@ -2556,6 +2557,7 @@ export class ChartView {
const style = t.style || {};
g.meshStrokeWidth = Number(style.stroke_width) || 0;
g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]);
g.strokeMatchFill = !!(t.stroke && t.stroke.mode === "match_fill");
}

// Hexbin ships cell centers plus one color value per cell; every hexagon
Expand Down Expand Up @@ -3511,7 +3513,7 @@ export class ChartView {
const stroke = g.meshStroke || [0, 0, 0, 0];
gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]);
gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0);
gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0);
gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : (g.strokeMatchFill ? 2 : 0));
gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style));
if (g.colorMode && g.lut) {
gl.activeTexture(gl.TEXTURE0);
Expand Down
64 changes: 64 additions & 0 deletions python/xy/_paint.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,70 @@
ColumnReader = Callable[[int], np.ndarray]


def triangle_mesh_boundary(*vertices: np.ndarray) -> np.ndarray | None:
"""Recover the single exterior ring of a tessellated simple polygon.

``Axes.fill`` reaches the shared triangle renderer for WebGL, but static
exporters should paint its triangulation as one polygon. Otherwise each
independently antialiased triangle leaks a hairline of background (and
applies translucent alpha more than once) along internal diagonals.
"""
if len(vertices) != 6:
raise ValueError("triangle mesh boundary requires six coordinate arrays")
arrays = [np.asarray(values, dtype=np.float64).reshape(-1) for values in vertices]
n = min((len(values) for values in arrays), default=0)
if n == 0:
return None
finite = np.concatenate(arrays)
span = float(np.nanmax(finite) - np.nanmin(finite))
# Each triangle coordinate is transported in an independently offset
# float32 column, so the same source vertex may decode a few ULPs apart in
# x0/x1/x2. The joined-fill flag is only used for one simple polygon; a
# generous relative bucket is still far below meaningful edge spacing.
tolerance = max(span * 2e-5, 1e-12)

def vertex_key(point: tuple[float, float]) -> tuple[int, int]:
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Global Tolerance Collapses Thin Features

Vertex identity uses 2e-5 of the polygon's full coordinate span. A large-span polygon with nearby but distinct vertices can therefore map those vertices to one key—for example, a span of 1e6 permits buckets around 20 units wide—causing the recovered boundary to drop edges, connect the wrong points, or fall back to visible triangle seams.

Artifacts

Repro: executable mesh and SVG-export harness

  • Evidence file captured while the check ran.

Repro: captured key collision, null boundary, and SVG fallback output

  • The full command output behind this check.

Global Tolerance Thin Feature Observed

  • What the screen looked like at this point in the check.

View artifacts

T-Rex Ran code and verified through T-Rex

return (round(point[0] / tolerance), round(point[1] / tolerance))

edge_counts: dict[tuple[tuple[int, int], tuple[int, int]], int] = {}
points_by_key: dict[tuple[int, int], tuple[float, float]] = {}
for index in range(n):
points = (
(float(arrays[0][index]), float(arrays[1][index])),
(float(arrays[2][index]), float(arrays[3][index])),
(float(arrays[4][index]), float(arrays[5][index])),
)
for start, end in zip(points, points[1:] + points[:1], strict=True):
start_key, end_key = vertex_key(start), vertex_key(end)
points_by_key.setdefault(start_key, start)
points_by_key.setdefault(end_key, end)
edge = (start_key, end_key) if start_key <= end_key else (end_key, start_key)
edge_counts[edge] = edge_counts.get(edge, 0) + 1
boundary = [edge for edge, count in edge_counts.items() if count == 1]
if len(boundary) < 3:
return None
adjacency: dict[tuple[int, int], list[tuple[int, int]]] = {}
for start, end in boundary:
adjacency.setdefault(start, []).append(end)
adjacency.setdefault(end, []).append(start)
if any(len(neighbors) != 2 for neighbors in adjacency.values()):
return None
first = boundary[0][0]
ring = [first]
previous: tuple[int, int] | None = None
current = first
for _ in range(len(boundary)):
neighbors = adjacency[current]
following = neighbors[0] if neighbors[0] != previous else neighbors[1]
if following == first:
if len(ring) != len(boundary):
return None
return np.asarray([points_by_key[key] for key in ring], dtype=np.float64)
ring.append(following)
previous, current = current, following
return None


def direct_rgba(channel: dict[str, Any], n: int, read_column: ColumnReader) -> np.ndarray | None:
"""Decode a packed normalized RGBA8 channel to canonical float RGBA."""
if channel.get("mode") != "direct_rgba":
Expand Down
16 changes: 14 additions & 2 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,7 +1538,9 @@ def read(index: int) -> np.ndarray:

x0, y0, x1, y1, x2, y2 = vertices
widths = _paint.style_values(t, "stroke_width", n, read, float(style.get("stroke_width", 0.0)))
if t.get("stroke") is not None:
if (t.get("stroke") or {}).get("mode") == "match_fill":
stroke_intrinsic = _trace_paint_rgba(t, "color", n, color, read)
elif t.get("stroke") is not None:
stroke_intrinsic = _trace_paint_rgba(t, "stroke", n, color, read)
elif style.get("stroke") is not None:
stroke_intrinsic = np.tile(
Expand All @@ -1554,6 +1556,14 @@ def read(index: int) -> np.ndarray:
projected = (sx(x0[:n]), sy(y0[:n]), sx(x1[:n]), sy(y1[:n]), sx(x2[:n]), sy(y2[:n]))
if n == 0:
return
if style.get("joined_fill") and np.all(fills == fills[0]) and np.all(widths == 0.0):
boundary = _paint.triangle_mesh_boundary(x0, y0, x1, y1, x2, y2)
if boundary is not None:
cmd.fill(
list(zip(sx(boundary[:, 0]), sy(boundary[:, 1]), strict=True)),
tuple(int(value) for value in fills[0]),
)
return
if np.all(widths == widths[0]) and np.all(strokes == strokes[0]):
cmd.triangles(
*projected,
Expand Down Expand Up @@ -1649,7 +1659,9 @@ def _rect_style_arrays(
* 255.0
).astype(np.uint8)
style = trace.get("style") or {}
if trace.get("stroke") is not None:
if (trace.get("stroke") or {}).get("mode") == "match_fill":
stroke_face = face
elif trace.get("stroke") is not None:
stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read)
elif style.get("stroke") is not None:
stroke_face = np.tile(
Expand Down
23 changes: 21 additions & 2 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -2251,7 +2251,9 @@ def read(index: int) -> np.ndarray:

face = _trace_paint_rgba(t, "color", n, fallback, read)
fills = _paint.effective_rgba(face, t, read, component="fill", default_opacity=1.0)
if t.get("stroke") is not None:
if (t.get("stroke") or {}).get("mode") == "match_fill":
stroke_face = face
elif t.get("stroke") is not None:
stroke_face = _trace_paint_rgba(t, "stroke", n, fallback, read)
elif style.get("stroke") is not None:
stroke_face = np.tile(
Expand All @@ -2265,6 +2267,21 @@ def read(index: int) -> np.ndarray:
t, "stroke_width", n, read, float(style.get("stroke_width", 0.0))
)
x0, y0, x1, y1, x2, y2 = vertices
if (
style.get("joined_fill")
and n
and np.all(fills == fills[0])
and np.all(stroke_widths == 0.0)
):
boundary = _paint.triangle_mesh_boundary(x0, y0, x1, y1, x2, y2)
if boundary is not None:
points = " ".join(f"{_num(float(sx(x)))},{_num(float(sy(y)))}" for x, y in boundary)
fill = fills[0]
return (
f'<polygon points="{points}" fill="rgb({round(fill[0] * 255)},'
f'{round(fill[1] * 255)},{round(fill[2] * 255)})" '
f'fill-opacity="{_num(float(fill[3]))}"/>'
)
out = ["<g>"]
for i in range(n):
points = " ".join(
Expand Down Expand Up @@ -2334,7 +2351,9 @@ def _rect_svg_styles(

face = _trace_paint_rgba(trace, "color", n, fallback, read)
fills_rgba = _paint.effective_rgba(face, trace, read, component="fill", default_opacity=0.85)
if trace.get("stroke") is not None:
if (trace.get("stroke") or {}).get("mode") == "match_fill":
stroke_face = face
elif trace.get("stroke") is not None:
stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read)
elif style.get("stroke") is not None:
stroke_face = np.tile(
Expand Down
6 changes: 6 additions & 0 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ def triangle_mesh(
opacity: Any = 1.0,
stroke: Any = None,
stroke_width: Any = 0.0,
_joined_fill: bool = False,
style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
x_axis: str = "x",
Expand All @@ -928,6 +929,9 @@ def triangle_mesh(
opacity: Triangle opacity from zero to one.
stroke: Optional triangle outline color.
stroke_width: Triangle outline width in pixels.
_joined_fill: Internal export hint; static exports fill a uniform, unstroked mesh as
one joined boundary ring instead of per-triangle fills, which avoids antialias
seams and repeated alpha along internal diagonals.
style: Mark style overrides.
class_name: Adapter-only trace metadata; it does not style canvas geometry.
x_axis: Identifier of the x axis used by this mark.
Expand All @@ -952,6 +956,7 @@ def triangle_mesh(
"opacity": opacity,
"stroke": stroke,
"stroke_width": stroke_width,
"_joined_fill": _joined_fill,
"x_axis": x_axis,
"y_axis": y_axis,
},
Expand Down Expand Up @@ -4613,6 +4618,7 @@ def _apply_triangle_mesh(fig: Figure, m: Mark, data: Any) -> None:
opacity=m.props["opacity"],
stroke=m.props["stroke"],
stroke_width=m.props["stroke_width"],
_joined_fill=m.props["_joined_fill"],
style=m.style,
)

Expand Down
30 changes: 27 additions & 3 deletions python/xy/marks.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ def triangle_mesh(
opacity: Any = 1.0,
stroke: Any = None,
stroke_width: Any = 0.0,
_joined_fill: bool = False,
style: styles.StyleMapping | None = None,
) -> "Figure":
"""Add independently colored filled triangles as one instanced mesh."""
Expand Down Expand Up @@ -431,10 +432,22 @@ def triangle_mesh(
if color_ch.mode != "continuous":
raise ValueError("triangle_mesh domain requires a continuous numeric color array")
color_ch.domain = self._finite_increasing_pair(domain, "triangle_mesh domain")
# A width without an explicit stroke means "outline in the face color".
# Constant paints already get that fallback from the renderer; direct and
# semantic color channels need the explicit buffer-free match mode.
if (
stroke_value is None
and stroke_ch is None
and color_ch.mode != "constant"
and (stroke_width_value or "stroke_width" in style_channels)
):
stroke_ch = channels.ColorChannel(mode="match_fill")
checkpoint = self._checkpoint()
try:
x0c, y0c, x1c, y1c, x2c, y2c = [self.store.ingest(values) for values in arrays]
style: dict[str, Any] = {"opacity": opacity_value, "role": "triangle-mesh"}
if _joined_fill:
style["joined_fill"] = True
style.update(styles._opacity_channels(css))
if stroke_value is not None:
style["stroke"] = stroke_value
Expand Down Expand Up @@ -698,6 +711,17 @@ def _bar_like(
mark_style["artist_alpha"] = alpha_values[index]
series_styles.append(mark_style)
series_channels.append(merged_channels)
if direct_strokes is None and direct_colors is not None:
resolved_strokes: list[Optional[channels.ColorChannel]] = [
(
channels.ColorChannel(mode="match_fill")
if stroke_width_values[index] or "stroke_width" in series_channels[index]
else None
)
for index in range(n_series)
]
else:
resolved_strokes = [None] * n_series if direct_strokes is None else list(direct_strokes)
checkpoint = self._checkpoint()
try:
if category_labels is not None:
Expand All @@ -719,7 +743,7 @@ def _bar_like(
role=f"{kind}-normalized" if mode == "normalized" else kind,
extra_style=series_styles[0],
color_ch=None if direct_colors is None else direct_colors[0],
stroke_ch=None if direct_strokes is None else direct_strokes[0],
stroke_ch=resolved_strokes[0],
style_channels=series_channels[0],
)
elif mode == "grouped":
Expand All @@ -739,7 +763,7 @@ def _bar_like(
role=f"{kind}-grouped",
extra_style=series_styles[i],
color_ch=None if direct_colors is None else direct_colors[i],
stroke_ch=None if direct_strokes is None else direct_strokes[i],
stroke_ch=resolved_strokes[i],
style_channels=series_channels[i],
)
else:
Expand All @@ -761,7 +785,7 @@ def _bar_like(
role=f"{kind}-{mode}",
extra_style=series_styles[i],
color_ch=None if direct_colors is None else direct_colors[i],
stroke_ch=None if direct_strokes is None else direct_strokes[i],
stroke_ch=resolved_strokes[i],
style_channels=series_channels[i],
)
pos_base = np.where(row >= 0, y1, pos_base)
Expand Down
Loading
Loading