Fix asymmetric rounded shell borders
This commit is contained in:
+139
-35
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user