From 6675f6b71179d6d3eaff61ccdd95fd573a1ad86a Mon Sep 17 00:00:00 2001 From: "J. S. Brown" Date: Tue, 28 Jul 2026 21:28:31 -0700 Subject: [PATCH] Fix asymmetric rounded shell borders --- shell/Commons/BorderGeometry.js | 174 ++++++++++++++---- shell/Ui/BorderOverlay.qml | 9 +- test/shell.d/border-geometry-test.sh | 254 ++++++++++++++++++++++++++- 3 files changed, 391 insertions(+), 46 deletions(-) diff --git a/shell/Commons/BorderGeometry.js b/shell/Commons/BorderGeometry.js index 96ba838a..0a909035 100644 --- a/shell/Commons/BorderGeometry.js +++ b/shell/Commons/BorderGeometry.js @@ -172,6 +172,11 @@ function normalizeRadii(w, h, r) { return { tl: tl, tr: tr, br: br, bl: bl } } +function appendArc(path, rx, ry, sweep, point) { + if (rx > 0 && ry > 0) path.push("A", rx, ry, 0, 0, sweep, point.x, point.y) + else path.push("L", point.x, point.y) +} + function roundedRectPath(x, y, w, h, radii) { if (w <= 0 || h <= 0) return "" var r = normalizeRadii(w, h, radii) @@ -181,61 +186,160 @@ function roundedRectPath(x, y, w, h, radii) { p.push("M", x + r.tl.rx, y) p.push("H", right - r.tr.rx) - if (r.tr.rx > 0 || r.tr.ry > 0) p.push("A", r.tr.rx, r.tr.ry, 0, 0, 1, right, y + r.tr.ry) + appendArc(p, r.tr.rx, r.tr.ry, 1, { x: right, y: y + r.tr.ry }) p.push("V", bottom - r.br.ry) - if (r.br.rx > 0 || r.br.ry > 0) p.push("A", r.br.rx, r.br.ry, 0, 0, 1, right - r.br.rx, bottom) + appendArc(p, r.br.rx, r.br.ry, 1, { x: right - r.br.rx, y: bottom }) p.push("H", x + r.bl.rx) - if (r.bl.rx > 0 || r.bl.ry > 0) p.push("A", r.bl.rx, r.bl.ry, 0, 0, 1, x, bottom - r.bl.ry) + appendArc(p, r.bl.rx, r.bl.ry, 1, { x: x, y: bottom - r.bl.ry }) p.push("V", y + r.tl.ry) - if (r.tl.rx > 0 || r.tl.ry > 0) p.push("A", r.tl.rx, r.tl.ry, 0, 0, 1, x + r.tl.rx, y) + appendArc(p, r.tl.rx, r.tl.ry, 1, { x: x + r.tl.rx, y: y }) p.push("Z") return p.join(" ") } -function ringPath(w, h, radius, widths) { +function borderBoundary(x, y, w, h, radii) { + var r = radii && radii.tl ? radii : normalizeRadii(w, h, radii) + var right = x + w + var bottom = y + h + return { + start: [ + { x: x + r.tl.rx, y: y }, + { x: right, y: y + r.tr.ry }, + { x: right - r.br.rx, y: bottom }, + { x: x, y: bottom - r.bl.ry }, + ], + end: [ + { x: right - r.tr.rx, y: y }, + { x: right, y: bottom - r.br.ry }, + { x: x + r.bl.rx, y: bottom }, + { x: x, y: y + r.tl.ry }, + ], + corner: [r.tr, r.br, r.bl, r.tl], + } +} + +function appendForwardCorner(path, boundary, side) { + var corner = boundary.corner[side] + appendArc(path, corner.rx, corner.ry, 1, boundary.start[(side + 1) % 4]) +} + +function appendReverseCorner(path, boundary, side) { + var corner = boundary.corner[side] + appendArc(path, corner.rx, corner.ry, 0, boundary.end[side]) +} + +function reverseBoundaryPath(boundary) { + var p = ["M", boundary.start[0].x, boundary.start[0].y] + for (var side = 3; side >= 0; side--) { + appendReverseCorner(p, boundary, side) + p.push("L", boundary.start[side].x, boundary.start[side].y) + } + p.push("Z") + return p.join(" ") +} + +function runPath(outer, inner, start, length) { + var previous = (start + 3) % 4 + var next = (start + length) % 4 + var p = ["M", outer.end[previous].x, outer.end[previous].y] + + appendForwardCorner(p, outer, previous) + for (var offset = 0; offset < length; offset++) { + var side = (start + offset) % 4 + p.push("L", outer.end[side].x, outer.end[side].y) + appendForwardCorner(p, outer, side) + } + + p.push("L", inner.start[next].x, inner.start[next].y) + for (var reverseOffset = length - 1; reverseOffset >= 0; reverseOffset--) { + var reverseSide = (start + reverseOffset) % 4 + appendReverseCorner(p, inner, reverseSide) + p.push("L", inner.start[reverseSide].x, inner.start[reverseSide].y) + } + appendReverseCorner(p, inner, previous) + p.push("Z") + return p.join(" ") +} + +function radiiFit(w, h, r) { + return r.tlrx + r.trrx <= w + && r.blrx + r.brrx <= w + && r.tlry + r.blry <= h + && r.trry + r.brry <= h +} + +// Internal geometry output used by ringPath and focused topology tests. +// Connected enabled-side runs share one closed contour; opposite-only sides +// need two. The all-sides case is one compound winding path with a reversed +// inner loop. Disabled sides never require touching or epsilon-offset inner +// geometry, so a zero/zero rounded corner emits no border pixels. +function borderPaths(w, h, radius, widths) { w = Math.max(0, Number(w) || 0) h = Math.max(0, Number(h) || 0) radius = Math.max(0, Number(radius) || 0) widths = widths || { top: 0, right: 0, bottom: 0, left: 0 } + if (w <= 0 || h <= 0) return [] - var outer = roundedRectPath(0, 0, w, h, { + var top = Math.max(0, Number(widths.top) || 0) + var right = Math.max(0, Number(widths.right) || 0) + var bottom = Math.max(0, Number(widths.bottom) || 0) + var left = Math.max(0, Number(widths.left) || 0) + var enabled = [top > 0, right > 0, bottom > 0, left > 0] + if (!enabled[0] && !enabled[1] && !enabled[2] && !enabled[3]) return [] + + var outerRadii = normalizeRadii(w, h, { tlrx: radius, tlry: radius, trrx: radius, trry: radius, brrx: radius, brry: radius, blrx: radius, blry: radius, }) - - // Shape's OddEven fill can collapse to the outer fill when the inner - // cutout touches the outer path on one or more zero-width sides. Keep the - // cutout strictly inside the outer path with a subpixel inset so one-sided - // borders (for example selected-border-width = "0 0 0 4") render as a - // strip instead of painting the whole row. - var epsilon = 0.001 - var left = Math.max(0, widths.left || 0) - var top = Math.max(0, widths.top || 0) - var right = Math.max(0, widths.right || 0) - var bottom = Math.max(0, widths.bottom || 0) - var ix = Math.max(left, epsilon) - var iy = Math.max(top, epsilon) - var ir = Math.max(right, epsilon) - var ib = Math.max(bottom, epsilon) - var iw = w - ix - ir - var ih = h - iy - ib - if (iw <= 0 || ih <= 0) return outer - - var inner = roundedRectPath(ix, iy, iw, ih, { - tlrx: Math.max(0, radius - left), - tlry: Math.max(0, radius - top), - trrx: Math.max(0, radius - right), - trry: Math.max(0, radius - top), - brrx: Math.max(0, radius - right), - brry: Math.max(0, radius - bottom), - blrx: Math.max(0, radius - left), - blry: Math.max(0, radius - bottom), + var outerPath = roundedRectPath(0, 0, w, h, { + tlrx: outerRadii.tl.rx, tlry: outerRadii.tl.ry, + trrx: outerRadii.tr.rx, trry: outerRadii.tr.ry, + brrx: outerRadii.br.rx, brry: outerRadii.br.ry, + blrx: outerRadii.bl.rx, blry: outerRadii.bl.ry, }) - return outer + " " + inner + var iw = w - left - right + var ih = h - top - bottom + if (iw <= 0 || ih <= 0) return [outerPath] + + var desiredInnerRadii = { + tlrx: Math.max(0, outerRadii.tl.rx - left), + tlry: Math.max(0, outerRadii.tl.ry - top), + trrx: Math.max(0, outerRadii.tr.rx - right), + trry: Math.max(0, outerRadii.tr.ry - top), + brrx: Math.max(0, outerRadii.br.rx - right), + brry: Math.max(0, outerRadii.br.ry - bottom), + blrx: Math.max(0, outerRadii.bl.rx - left), + blry: Math.max(0, outerRadii.bl.ry - bottom), + } + + // Normalizing an inner radius that cannot fit can move its tangent beyond + // the outer rounded boundary. Winding fill may then paint outside the outer + // contour. Conservatively treat that rounded interior as consumed instead. + if (!radiiFit(iw, ih, desiredInnerRadii)) return [outerPath] + + var innerRadii = normalizeRadii(iw, ih, desiredInnerRadii) + var outer = borderBoundary(0, 0, w, h, outerRadii) + var inner = borderBoundary(left, top, iw, ih, innerRadii) + + if (enabled[0] && enabled[1] && enabled[2] && enabled[3]) + return [outerPath + " " + reverseBoundaryPath(inner)] + + var paths = [] + for (var start = 0; start < 4; start++) { + if (!enabled[start] || enabled[(start + 3) % 4]) continue + var length = 1 + while (length < 4 && enabled[(start + length) % 4]) length++ + paths.push(runPath(outer, inner, start, length)) + } + return paths +} + +function ringPath(w, h, radius, widths) { + return borderPaths(w, h, radius, widths).join(" ") } function gradientEndpoints(w, h, angle) { diff --git a/shell/Ui/BorderOverlay.qml b/shell/Ui/BorderOverlay.qml index 4fd0fb6e..f67c9e5f 100644 --- a/shell/Ui/BorderOverlay.qml +++ b/shell/Ui/BorderOverlay.qml @@ -3,10 +3,9 @@ import QtQuick.Shapes import qs.Commons import "../Commons/BorderGeometry.js" as Geometry -// Visual-only border renderer. It draws a filled rounded ring so borders can -// have gradients and independent top/right/bottom/left widths. Flat uniform -// borders should stay on Rectangle.border; this component is the fallback for -// cases Rectangle cannot represent. +// Visual-only border renderer. It draws closed side-run contours, or a +// compound winding path for all four sides, so asymmetric/gradient borders +// do not require touching odd-even paths. Flat uniform borders use Rectangle.border. Item { id: root @@ -29,7 +28,7 @@ Item { preferredRendererType: Shape.CurveRenderer ShapePath { - fillRule: ShapePath.OddEvenFill + fillRule: ShapePath.WindingFill strokeWidth: 0 fillGradient: LinearGradient { x1: root._endpoints.x1 diff --git a/test/shell.d/border-geometry-test.sh b/test/shell.d/border-geometry-test.sh index 9c1d70ed..e0b15e29 100755 --- a/test/shell.d/border-geometry-test.sh +++ b/test/shell.d/border-geometry-test.sh @@ -34,15 +34,257 @@ assertEqual( 'border geometry converts legacy ARGB color to QML RGBA hex' ) -const pathData = geometry.ringPath(100, 50, 10, { top: 4, right: 2, bottom: 8, left: 6 }) -assert(pathData.includes('M 10 0'), 'border geometry emits outer rounded path') -assert(pathData.includes('M 10 4'), 'border geometry emits inset inner rounded path') +function pathsFor(widths, radius = 10, w = 100, h = 50) { + return geometry.borderPaths(w, h, radius, widths) +} -const oneSidedPath = geometry.ringPath(100, 50, 10, { top: 0, right: 0, bottom: 0, left: 4 }) -assert(oneSidedPath.includes('M 10 0.001'), 'border geometry keeps zero-width top inside outer path') -assert(oneSidedPath.includes('99.999 10.001'), 'border geometry keeps zero-width right inside outer path') +function assertValidPaths(paths, description) { + const data = paths.join(' ') + assert(!/(NaN|Infinity|0\.001)/.test(data), `${description} has finite exact geometry`) + assert(!/\bA\s+0(?:\.0+)?\s/.test(data), `${description} has no zero-width arcs`) + assert(!/\bA\s+\S+\s+0(?:\.0+)?\s/.test(data), `${description} has no zero-height arcs`) + for (const borderPath of paths) { + assert(/^M\s/.test(borderPath) && /\sZ$/.test(borderPath), `${description} emits closed contours`) + } +} + +function vectorAngle(ux, uy, vx, vy) { + const dot = ux * vx + uy * vy + const length = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) + const angle = Math.acos(Math.max(-1, Math.min(1, dot / length))) + return ux * vy - uy * vx < 0 ? -angle : angle +} + +function flattenArc(from, rx, ry, largeArc, sweep, to) { + const dx = (from.x - to.x) / 2 + const dy = (from.y - to.y) / 2 + let scale = dx * dx / (rx * rx) + dy * dy / (ry * ry) + if (scale > 1) { + scale = Math.sqrt(scale) + rx *= scale + ry *= scale + } + + const numerator = Math.max(0, rx * rx * ry * ry - rx * rx * dy * dy - ry * ry * dx * dx) + const denominator = rx * rx * dy * dy + ry * ry * dx * dx + const factor = (largeArc === sweep ? -1 : 1) * Math.sqrt(numerator / denominator) + const centerXPrime = factor * rx * dy / ry + const centerYPrime = factor * -ry * dx / rx + const centerX = centerXPrime + (from.x + to.x) / 2 + const centerY = centerYPrime + (from.y + to.y) / 2 + const startX = (dx - centerXPrime) / rx + const startY = (dy - centerYPrime) / ry + const endX = (-dx - centerXPrime) / rx + const endY = (-dy - centerYPrime) / ry + const startAngle = vectorAngle(1, 0, startX, startY) + let deltaAngle = vectorAngle(startX, startY, endX, endY) + if (!sweep && deltaAngle > 0) deltaAngle -= 2 * Math.PI + if (sweep && deltaAngle < 0) deltaAngle += 2 * Math.PI + + const points = [] + const steps = 16 + for (let step = 1; step <= steps; step++) { + const angle = startAngle + deltaAngle * step / steps + points.push({ x: centerX + rx * Math.cos(angle), y: centerY + ry * Math.sin(angle) }) + } + return points +} + +function flattenPaths(paths) { + const contours = [] + for (const pathData of paths) { + const tokens = pathData.trim().split(/\s+/) + let index = 0 + let current = null + let contour = null + while (index < tokens.length) { + const command = tokens[index++] + if (command === 'M') { + current = { x: Number(tokens[index++]), y: Number(tokens[index++]) } + contour = [current] + contours.push(contour) + } else if (command === 'L') { + current = { x: Number(tokens[index++]), y: Number(tokens[index++]) } + contour.push(current) + } else if (command === 'H') { + current = { x: Number(tokens[index++]), y: current.y } + contour.push(current) + } else if (command === 'V') { + current = { x: current.x, y: Number(tokens[index++]) } + contour.push(current) + } else if (command === 'A') { + const rx = Number(tokens[index++]) + const ry = Number(tokens[index++]) + const rotation = Number(tokens[index++]) + const largeArc = Number(tokens[index++]) + const sweep = Number(tokens[index++]) + const end = { x: Number(tokens[index++]), y: Number(tokens[index++]) } + if (rotation !== 0) throw new Error('test path flattener only supports unrotated border arcs') + contour.push(...flattenArc(current, rx, ry, largeArc, sweep, end)) + current = end + } else if (command === 'Z') { + contour.push(contour[0]) + current = contour[0] + } else { + throw new Error(`unsupported path command ${command}`) + } + } + } + return contours +} + +function pathContains(paths, x, y) { + let winding = 0 + for (const contour of flattenPaths(paths)) { + for (let index = 0; index < contour.length - 1; index++) { + const from = contour[index] + const to = contour[index + 1] + const cross = (to.x - from.x) * (y - from.y) - (x - from.x) * (to.y - from.y) + if (from.y <= y && to.y > y && cross > 0) winding++ + if (from.y > y && to.y <= y && cross < 0) winding-- + } + } + return winding !== 0 +} + +function flattenedBounds(paths) { + const points = flattenPaths(paths).flat() + return { + minX: Math.min(...points.map(point => point.x)), + maxX: Math.max(...points.map(point => point.x)), + minY: Math.min(...points.map(point => point.y)), + maxY: Math.max(...points.map(point => point.y)), + } +} + +const selectedPaths = pathsFor({ top: 0, right: 0, bottom: 1, left: 3 }) +assertEqual(selectedPaths.length, 1, 'adjacent left and bottom borders share one contour') +assert( + selectedPaths[0].includes('A 10 10 0 0 1 90 50') + && selectedPaths[0].includes('A 10 10 0 0 1 0 40') + && selectedPaths[0].includes('A 10 10 0 0 1 10 0'), + 'selected border retains bottom, bottom-left, and left geometry' +) +assert(!pathContains(selectedPaths, 95, 5), 'selected border leaves the upper-right region empty') +assert(!pathContains(selectedPaths, 50, 25), 'selected border leaves the row center empty') +assert(pathContains(selectedPaths, 1, 25), 'selected border paints the left edge') +assert(pathContains(selectedPaths, 50, 49.5), 'selected border paints the bottom edge') +assertValidPaths(selectedPaths, 'selected border') + +const leftRounded = pathsFor({ top: 0, right: 0, bottom: 0, left: 4 }) +assertEqual(leftRounded.length, 1, 'rounded left-only border emits one contour') +assert( + leftRounded[0].includes('A 10 10 0 0 1 0 40') + && leftRounded[0].includes('A 10 10 0 0 1 10 0') + && !leftRounded[0].includes('A 10 10 0 0 1 100 10') + && !leftRounded[0].includes('A 10 10 0 0 1 90 50'), + 'rounded left-only border contains only its adjoining outer corners' +) +assert(flattenedBounds(leftRounded).maxX <= 10, 'rounded left-only geometry stays localized to the left corner radius') +assert(pathContains(leftRounded, 1, 25), 'rounded left-only border paints the left edge') +assert(!pathContains(leftRounded, 50, 25), 'rounded left-only border leaves the center empty') +assert(!pathContains(leftRounded, 50, 1), 'rounded left-only border leaves the top edge empty') +assert(!pathContains(leftRounded, 99, 25), 'rounded left-only border leaves the right edge empty') +assert(!pathContains(leftRounded, 50, 49), 'rounded left-only border leaves the bottom edge empty') +assertValidPaths(leftRounded, 'rounded left-only border') + +const leftSquare = pathsFor({ top: 0, right: 0, bottom: 0, left: 4 }, 0) +assertEqual(leftSquare.length, 1, 'square left-only border emits one rectangle contour') +assert(!leftSquare[0].includes('A ') && !leftSquare[0].includes('100'), 'square left-only border never becomes a full-row fill') +assert(leftSquare[0].includes('L 4 0') && leftSquare[0].includes('L 4 50'), 'square left-only border is bounded by its requested width') +assertValidPaths(leftSquare, 'square left-only border') + +const outerCorners = [ + 'A 10 10 0 0 1 100 10', + 'A 10 10 0 0 1 90 50', + 'A 10 10 0 0 1 0 40', + 'A 10 10 0 0 1 10 0', +] +const isolated = [ + { name: 'top', widths: { top: 4, right: 0, bottom: 0, left: 0 }, corners: [0, 3] }, + { name: 'right', widths: { top: 0, right: 4, bottom: 0, left: 0 }, corners: [0, 1] }, + { name: 'bottom', widths: { top: 0, right: 0, bottom: 4, left: 0 }, corners: [1, 2] }, + { name: 'left', widths: { top: 0, right: 0, bottom: 0, left: 4 }, corners: [2, 3] }, +] +for (const testCase of isolated) { + const paths = pathsFor(testCase.widths) + assertEqual(paths.length, 1, `${testCase.name}-only border emits one contour`) + for (let corner = 0; corner < 4; corner++) { + assertEqual( + paths[0].includes(outerCorners[corner]), + testCase.corners.includes(corner), + `${testCase.name}-only border ${testCase.corners.includes(corner) ? 'includes' : 'omits'} outer corner ${corner}` + ) + } + assertValidPaths(paths, `${testCase.name}-only border`) +} + +for (let mask = 0; mask < 16; mask++) { + const widths = { + top: mask & 1 ? 3 : 0, + right: mask & 2 ? 3 : 0, + bottom: mask & 4 ? 3 : 0, + left: mask & 8 ? 3 : 0, + } + const paths = pathsFor(widths) + const expectedRuns = mask === 0 ? 0 : (mask === 5 || mask === 10 ? 2 : 1) + assertEqual(paths.length, expectedRuns, `enabled-side mask ${mask.toString(2).padStart(4, '0')} has minimal connected contours`) + assertEqual(geometry.ringPath(100, 50, 10, widths), paths.join(' '), `enabled-side mask ${mask.toString(2).padStart(4, '0')} joins without changing callers`) + assertValidPaths(paths, `enabled-side mask ${mask.toString(2).padStart(4, '0')}`) +} + +const horizontalOpposites = pathsFor({ top: 3, right: 0, bottom: 5, left: 0 }) +const verticalOpposites = pathsFor({ top: 0, right: 3, bottom: 0, left: 5 }) +assertEqual(horizontalOpposites.length, 2, 'opposite top and bottom borders emit disconnected contours') +assertEqual(verticalOpposites.length, 2, 'opposite left and right borders emit disconnected contours') +assertEqual( + geometry.ringPath(100, 50, 10, { top: 3, right: 0, bottom: 5, left: 0 }), + horizontalOpposites.join(' '), + 'joined opposite contours retain one global ShapePath and gradient space' +) +assertValidPaths(horizontalOpposites, 'horizontal opposite borders') +assertValidPaths(verticalOpposites, 'vertical opposite borders') + +assertEqual(pathsFor({ top: 0, right: 0, bottom: 0, left: 0 }).length, 0, 'all-zero widths emit no geometry') +assertEqual(pathsFor({ top: -4, right: 0, bottom: 0, left: 0 }).length, 0, 'negative widths clamp to zero') + +for (const width of [10, 14]) { + const paths = pathsFor({ top: 0, right: 0, bottom: 0, left: width }) + assertEqual(paths.length, 1, `left width ${width} remains a one-sided contour`) + assertValidPaths(paths, `left width ${width}`) +} + +const consumedWidth = pathsFor({ top: 0, right: 60, bottom: 0, left: 40 }) +const consumedHeight = pathsFor({ top: 30, right: 0, bottom: 20, left: 0 }) +const nearConsumedRounded = pathsFor({ top: 1, right: 1, bottom: 1, left: 98 }) +assertEqual(consumedWidth.length, 1, 'consumed inner width emits one outer fill') +assertEqual(consumedHeight.length, 1, 'consumed inner height emits one outer fill') +assertEqual(nearConsumedRounded.length, 1, 'near-consumed rounded interior emits one conservative outer fill') +assertEqual(consumedWidth[0], geometry.roundedRectPath(0, 0, 100, 50, { + tlrx: 10, tlry: 10, trrx: 10, trry: 10, + brrx: 10, brry: 10, blrx: 10, blry: 10, +}), 'consumed inner width returns the outer rounded shape') +assertEqual(consumedHeight[0], consumedWidth[0], 'consumed inner height returns the same outer rounded shape') +assertEqual(nearConsumedRounded[0], consumedWidth[0], 'unfittable desired inner radii return the outer rounded shape before normalization') + +const allPositive = pathsFor({ top: 4, right: 2, bottom: 8, left: 6 }) +assertEqual(allPositive.length, 1, 'all-positive asymmetric border emits one winding contour') +assertEqual((allPositive[0].match(/\bM\b/g) || []).length, 2, 'all-positive contour contains outer and reversed inner loops') +assertValidPaths(allPositive, 'all-positive asymmetric border') + +const flatUniform = { widths: { top: 2, right: 2, bottom: 2, left: 2 }, gradient: { enabled: false } } +const flatAsymmetric = { widths: { top: 0, right: 0, bottom: 1, left: 3 }, gradient: { enabled: false } } +const gradientUniform = { widths: { top: 2, right: 2, bottom: 2, left: 2 }, gradient: { enabled: true } } +assert(geometry.canUseNative(flatUniform), 'flat uniform borders retain native Rectangle routing') +assert(!geometry.needsOverlay(flatUniform), 'flat uniform borders do not need the overlay') +assert(geometry.needsOverlay(flatAsymmetric), 'flat asymmetric borders use the overlay') +assert(geometry.needsOverlay(gradientUniform), 'uniform gradient borders use the overlay') const endpoints = geometry.gradientEndpoints(100, 50, 0) assertEqual(Math.round(endpoints.x1), 0, 'border geometry 0deg starts at left edge') assertEqual(Math.round(endpoints.x2), 100, 'border geometry 0deg ends at right edge') + +const overlayQml = fs.readFileSync(path.join(root, 'shell/Ui/BorderOverlay.qml'), 'utf8') +assert(overlayQml.includes('ShapePath.WindingFill'), 'border overlay uses winding fill for side-run and compound paths') +assert(!overlayQml.includes('ShapePath.OddEvenFill'), 'border overlay no longer uses touching odd-even geometry') JS