Fix asymmetric rounded shell borders

This commit is contained in:
J. S. Brown
2026-07-28 21:28:31 -07:00
parent 38c1352f6d
commit 6675f6b711
3 changed files with 391 additions and 46 deletions
+139 -35
View File
@@ -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) {
+4 -5
View File
@@ -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
+248 -6
View File
@@ -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