Skip to content
Open
Changes from 1 commit
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
Next Next commit
[css-borders-4] Refactor contour path algorithm
Use vector math to better describe how to draw a border-align contour.
Also use the miter points (which are the axisAlignedCornerStart/End points)
to make sure shadows are rendered in a border-aligned manner.

Closes #13037
  • Loading branch information
noamr committed Dec 1, 2025
commit 85f83d683892e4413d3bace06a9006cab6f92860
213 changes: 129 additions & 84 deletions css-borders-4/Overview.bs
Original file line number Diff line number Diff line change
Expand Up @@ -1459,101 +1459,146 @@ Rendering 'corner-shape'</h4>
When rendering elements with shaped corners, the element's path needs to be offset,
based on [=border=], [=outline=], 'box-shadow', 'overflow-clip-margin' and more.

When rendering borders or outlines, the offset is aligned to the curve of the element's shape,
while when rendering 'box-shadow' or offsetting for 'overflow-clip-margin', the offset is aligned to the axis.

<figure>
<img src="images/corner-shape-adjusting.svg" width="600" height="537"
alt="Adjusting corner shapes"
>
<figcaption>Borders are aligned to the curve, shadows and clip are aligned to the axis.</figcaption>
</figure>

When rendering borders or outlines, the offset is aligned to the curve of the element's shape.
When rendering 'box-shadow' or offsetting for 'overflow-clip-margin', the offset is also aligned to the same curve in the other direction, and the curve continues to the outer edge.

An [=/element=] |element|'s <dfn>outer contour</dfn> is the [=border contour path=] given |element| and |element|'s [=border edge=].

An [=/element=] |element|'s <dfn>inner contour</dfn> is the [=border contour path=] given |element| and |element|'s [=padding edge=].
An [=/element=] |element|'s <dfn>inner contour</dfn> is the [=border contour path=] given |element| and |element|'s [=border width=].

An [=/element=]'s [=border=] is rendered in the area between its [=outer contour=] and its [=inner contour=].

An [=/element=]'s [=outline=] follows the [=outer contour=] with the [=used value|used=] 'outline-width' and 'outline-offset'.
The precise way in which it is rendered is implementation-defined.

An [=/element=]'s [=overflow=] area is shaped by its [=inner contour=].
An [=/element=]'s [=overflow clip edge=] is shaped by the [=border contour path=] given |element|, and |element|'s [=padding edge=], and |element|'s [=used value|used=] 'overflow-clip-margin'.

Each shadow of [=/element=]'s 'box shadow' is shaped by the [=border contour path=] given |element|, and |element|'s [=border edge=], and the shadow's [=used value|used=] 'box-shadow-spread'.

<div algorithm="adjust-border-inner-path-for-corner-shape">
To compute an [=/element=] |element|'s <dfn>border contour path</dfn> given an [=edge=] |targetEdge| and an optional number |spread| (default 0):
1. Let |outerLeft|, |outerTop|, |outerRight|, |outerBottom| be |element|'s [=unshaped edge|unshaped=] [=border edge=], outset by |spread|.
1. Let |topLeftHorizontalRadius|, |topLeftVericalRadius|, |topRightHorizontalRadius|, |topRightVerticalRadius|, |bottomRightHorizontalRadius|,
|bottomRightVerticalRadius|, |bottomLeftHorizontalRadius|, and |bottomLeftVerticalRadius| be |element| [=border edge=]'s radii,
scaled by |element|'s [=opposite corner scale factor=] and [=outset-adjusted border radius|outset-adjusted=].
1. Let |topLeftShape|, |topRightShape|, |bottomRightShape|, and |bottomLeftShape| be |element|'s [=computed value|computed=] 'corner-*-shape' values.
1. Let |targetLeft|, |targetTop|, |targetRight|, |targetBottom| [=unshaped edge|unshaped=] |targetEdge|.
1. Let |path| be a new path [[SVG2]].
1. [=Add corner to path=] given |path|,
the [=rectangle=] <code>(|outerRight| - |topRightHorizontalRadius|, |outerTop|, |topRightHorizontalRadius|, |topRightVerticalRadius|)</code>, |targetEdge|,
0, |targetTop| - |outerTop|, |outerRight| - |targetRight|, and |topRightShape|.
1. [=Add corner to path=] given |path|,
the [=rectangle=] <code>(|outerRight| - |bottomRightHorizontalRadius|, |outerBottom| - |bottomRightVerticalRadius|, |bottomRightHorizontalRadius|, |bottomRightVerticalRadius|)</code>, |targetEdge|,
1, |outerRight| - |targetRight|, |outerBottom| - |targetBottom|, and |bottomRightShape|.
1. [=Add corner to path=] given |path|,
the [=rectangle=] <code>(|outerLeft|, |outerBottom| - |bottomLeftVerticalRadius|, |bottomLeftHorizontalRadius|, |bottomLeftVerticalRadius|)</code>, |targetEdge|,
2, |outerBottom| - |targetBottom|, |targetLeft| - |outerLeft|, and |bottomLeftShape|.
1. [=Add corner to path=] given |path|,
the [=rectangle=] <code>(|outerLeft|, |outerTop|, |topLeftHorizontalRadius|, |topLeftVericalRadius|)</code>, |targetEdge|,
3, |targetLeft| - |outerLeft|, |targetTop| - |outerTop|, and |topLeftShape|.
1. Return |path|.
An [=/element=]'s [=overflow clip edge=] is shaped by the [=border contour path=] given |element|, and an [=inset from a uniform outset=] given |element|'s [=used value|used=] 'overflow-clip-margin'.

Each shadow of [=/element=]'s 'box shadow' is shaped by the [=border contour path=] given |element|, and an [=inset from a uniform outset=] given the shadow's [=used value|used=] 'box-shadow-spread'.

An <dfn>inset from a uniform outset</dfn> given a number |outset| is an [=edge=] whose value is <code>-|outset|</code> in all directions.

<h5 id=vector-math-helpers>Vector Math Helpers</h5>

A <dfn>two-dimensional vector</dfn> is a pair of numbers (, y).
<div algorithm="extend-point">
A <dfn>point extended by vectors</dfn> given a {{DOMPointReadOnly}} |p| and a [=/list=] of [=two-dimensional vector=]s |vectors|:
1. Let |x| by |p|'s {{DOMPointReadOnly/x}}.
1. Let |y| by |p|'s {{DOMPointReadOnly/y}}.
1. [=list/For each=] |v| in |vectors|:
1. Increment |x| by |v|[0];
1. Increment |y| by |v|[1];
1. Return a new {{DOMPointReadOnly}} whose {{DOMPointReadOnly/x}} is |x| and whose {{DOMPointReadOnly/y}} is |y|.
</div>

To <dfn for="two-dimensional vector">scale</dfn> a [=two-dimensional vector=] |v| by a number |factor|, return <code>(|v|[0] ⋅ |factor|, |v|[1] ⋅ |factor|)</code>.

<div algorithm="normalize-vector">
To <dfn for="two-dimensional vector">normalize</dfn> a [=two-dimensional vector=] |v|:
1. Let |l| be <code>hypot(|v|[0], |v|[1])</code>.
1. If |l| is 0, return |v|.
1. Return <code>(|v|[0] / |l|, |v|[1] / |l|)</code>.
</div>

To <dfn>add corner to path</dfn> given a path |path|, a rectangle |cornerRect|, a rectangle |trimRect|,
and numbers |orientation|, |startThickness|, |endThickness|, |curvature|:

1. If |cornerRect| is empty, or if |curvature| is &infin;:
1. Let |innerQuad| be |trimRect|'s [=clockwise quad=] .
1. Extend |path| by drawing a line to |innerQuad|[<code>(|orienation| + 1) % 4</code>].
1. Return.

1. Let |cornerQuad| be |cornerRect|'s [=clockwise quad=].
1. If |curvature| is -&infin;:
1. Extend |path| by drawing a line from |cornerQuad|[0] to |cornerQuad|[3], trimmed by |trimRect|.
1. Extend |path| by drawing a line from |cornerQuad|[3] to |cornerQuad|[2], trimmed by |trimRect|.
1. Return.

1. Let |clampedNormalizedHalfCorner| be the [=normalized superellipse half corner=] given <code>clamp(|curvature|, -1, 1)</code>.
1. Let |equivalentQuadraticControlPointX| be <code>|clampedNormalizedHalfCorner| * 2 - 0.5</code>.
1. Let |curveStartPoint| be the [=aligned corner point=] given |cornerQuad|[|orienation|], the vector (|equivalentQuadraticControlPointX|, <code>1 - |equivalentQuadraticControlPointX|</code>), |startThickness|, and |orientation| + 1.
1. Let |curveEndPoint| by the [=aligned corner point=] given |cornerQuad|[(|orientation| + 2) % 4], the vector (<code>|equivalentQuadraticControlPointX| - 1</code>, <code>-|equivalentQuadraticControlPointX|</code>), |endThickness|, and |orientation| + 3.
1. Let |alignedCornerRect| be a [=rectangle=] that includes the points |curveStartPoint| and |curveEndPoint|.
1. Let |projectionToCornerRect| be a [=transformation matrix=],
translated by <code>(|alignedCornerRect|'s [=x coordinate=], |alignedCornerRect|'s [=y coordinate=])</code>,
scaled by <code>(|alignedCornerRect|'s [=width dimension=], |alignedCornerRect|'s [=height dimension=])</code>,
translated by <code>(0.5, 0.5)</code>,
rotated by <code>90deg * orientation</code>,
and translated by <code>(-0.5, -0.5)</code>.

1. Let |K| be <code>0.5<sup>abs(|curvature|)</sup></code>.
1. For each |T| between 0 and 1:
1. Let |A| be <code>|T|<sup>|K|</sup></code>.
1. Let |B| be <code>1 - (1 - |T|)<sup>|K|</sup></code>.
1. Let |normalizedPoint| be <code>(|A|, |B|)</code> if |curvature| is positive, otherwise <code>(|B|, |A|)</code>.
1. Let |absolutePoint| be |normalizedPoint|, transformed by |projectionToCornerRect|.
1. If |absolutePoint| is within |trimRect|, extend |path| through |absolutePoint|.

Note: User agents may approximate this algorithm, for instance, by using concatenated Bezier curves, to balance between performance and rendering accuracy.

To compute the <dfn>aligned corner point</dfn> given a point |originalPoint|, a two-component vector |offsetFromControlPoint|, a number |thickness|, and a number |orientation|:
1. Let |length| be <code>hypot(|offsetFromControlPoint|.x, |offsetFromControlPoint|.y)</code>.
1. Rotate |offsetFromControlPoint| by <code>90deg * |orientation|</code>, and scale by |thickness|.
1. Translate |originalPoint| by <code>|offsetFromControlPoint|.x / |length|, |offsetFromControlPoint|.y / |length|</code>, and return the result.

The <dfn>clockwise quad</dfn> given a [=rectangle=] |rect|, is a [=quadrilateral=] with the points
(|rect|'s [=x coordinate=], |rect|'s [=y coordinate=]),
(|rect|'s [=x coordinate=] + |rect|'s [=width dimension=], |rect|'s [=y coordinate=]),
(|rect|'s [=x coordinate=] + |rect|'s [=width dimension=], |rect|'s [=y coordinate=] + |rect|'s [=height dimension=]),
(|rect|'s [=x coordinate=], |rect|'s [=y coordinate=] + |rect|'s [=height dimension=]).
The <dfn for="two-dimensional vector">perpendicular</dfn> of a [=two-dimensional vector=] |v| is <code>(-|v|[1], |v|[0])</code>.

The <dfn>vector between two points</dfn>, given {{DOMPoint}}s |a| and |b|, is <code>(|b|'s {{DOMPointReadOnly/x}} - |a|'s {{DOMPointReadOnly/x}}, |b|'s {{DOMPointReadOnly/y}} - |a|'s {{DOMPointReadOnly/y}})</code>.

<h5 id=contour-path>Computing a contoured path</h5>

This algorithm describes how to compute a path with shaped corners (a path that is either inset or outset from the original).
To avoid the complexities of defining how superellipses intersect, the algorithm simplifies the process by specifying that each corner is "clipped out" of the path.
The specific implementation details of this clipping operation are left to implementations.

<div algorithm="border-aligned-contour-path">
To compute an [=/element=] |element|'s <dfn>border contour path</dfn> given numbers |topInset|, |rightInset|, |bottomInset|, |leftInset|:
1. Let |borderRect| be |element|'s [=border box=].
1. Let |unshapedTargetRect| be |borderRect|, inset by |topInset|, |rightInset|, |bottomInset|, |leftInset|.

Note: If this is a shadow or 'overflow-clip-margin', the insets would have negative values and |unshapedTargetRect| would become an outset of |borderRect|.

1. Let |path| be a path that contains |unshapedTargetRect|.

1. Let |scaleFactor| be the [=opposite corner scale factor=] given |element|.

1. Let |adjustedRadius| be the following steps given a property |P|, and numbers |insetX| and |insetY|:
1. Let |radius| be |element|'s [=used value=] of |P|.
1. If |insetX| and |insetY| are zero, return |radius|.
1. If |insetX| or |insetY| are positive, then return <code>(|radius|'s [=width=] ⋅ |scaleFactor|, |radius|'s [=height=] ⋅ |scaleFactor|)</code>.
1. Let |adjsutedRadiusInOutsetCoordinates| be the [=outset-adjusted border radius=] given |borderRect|'s size, |radius|, and <code>(-|insetX|, -|insetY|)</code>.
1. Return <code>(|adjsutedRadiusInOutsetCoordinates|'s [=width=] - |insetX|, |adjsutedRadiusInOutsetCoordinates|'s [=height=] - |insetY|)</code>.

1. Let |adjustedTopRightRadius| be the |adjustedRadius| given 'border-top-right-radius', |insetRight|, and |insetTop|.
1. Let |adjustedBottomRightRadius| be the |adjustedRadius| given 'border-bottom-right-radius', |insetRight|, and |insetBottom|.
1. Let |adjustedBottomLeftRadius| be the |adjustedRadius| given 'border-bottom-left-radius', |insetLeft|, and |insetBottom|.
1. Let |adjustedTopLeftRadius| be the |adjustedRadius| given 'border-top-left-radius', |insetLeft|, and |insetTop|.

1. Clip out from |path|, the [=border-aligned corner clip-out path=] given
|borderRect|'s right, |borderRect|'s top,
<code>(-|adjustedTopRightRadius|[0], 0)</code>, <code>(0, |adjustedTopRightRadius|[1])</code>,
|element|'s [=computed value|computed=] 'corner-top-right-shape',
|topInset|, and |rightInset|.

1. Clip out from |path|, the [=border-aligned corner clip-out path=] given
|borderRect|'s right, |borderRect|'s bottom,
<code>(0, -|adjustedBottomRightRadius|[1])</code>, <code>(-|adjustedTopRightRadius|[0], 0)</code>,
|element|'s [=computed value|computed=] 'corner-bottom-right-shape',
|rightInset|, and |bottomInset|.

1. Clip out from |path|, the [=border-aligned corner clip-out path=] given
|borderRect|'s left, |borderRect|'s bottom,
<code>(|adjustedBottomLeftRadius|[0], 0)</code>, <code>(0, -|adjustedBottomLeftRadius|[1])</code>,
|element|'s [=computed value|computed=] 'corner-bottom-left-shape',
|bottomInset|, and |leftInset|.

1. Clip out from |path|, the [=border-aligned corner clip-out path=] given
|borderRect|'s left, |borderRect|'s top,
<code>(0, |adjustedTopLeftRadius|[1])</code>, <code>(|adjustedTopLeftRadius|[0], 0)</code>,
|element|'s [=computed value|computed=] 'corner-top-left-shape',
|leftInset|, and |topInset|.

1. Return |path|.

To get the <dfn>border-aligned corner clip-out path</dfn> given a {{DOMPointReadOnly}} |originalCornerOuter|, a [=two-dimensional vector=] |vectorTowardsStart|, a [=two-dimensional vector=] |vectorTowardsEnd|, a [=superellipse parameter=] |curvature|, and numbers |startInset| and |endInset|:
1. If |curvature| is &infin;, then return an empty path.
1. Let |clampedHalfCorner| be the [=normalized superellipse half corner=] given <code>clamp(|curvature|, -1, 1)</code>.
1. Let |originalCornerStart| be |originalCornerOuter|, [=point extended by vectors|extended by=] « |vectorTowardsStart| ».
1. Let |originalCornerEnd| be |originalCornerOuter|, [=point extended by vectors|extended by=] « |vectorTowardsEnd| ».
1. Let |originalCornerCenter| be |originalCornerOuter|, [=point extended by vectors|extended by=] « |vectorTowardsStart|, |vectorTowardsEnd| ».
1. Let |extendStart| be a [=vector between two points|vector between=] |originalCornerStart| and |originalCornerCenter|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] |startInset|.
1. Let |extendEnd| be a [=vector between two points|vector between=] |originalCornerEnd| and |originalCornerCenter|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] |endInset|.
1. Let |clipStart| be |originalCornerStart|, [=point extended by vectors|extended by=] « |extendStart| ».
1. Let |clipEnd| be |originalCornerEnd|, [=point extended by vectors|extended by=] « |extendEnd| ».
1. Let |clipOuter| be |originalCornerOuter|, [=point extended by vectors|extended by=] « |extendStart|, |extendEnd| ».
1. Let |vectorFromStartToControlPoint| be the the [=two-dimensional vector=] <code>(2 ⋅ |clampedHalfCorner| - 0.5, 1.5 - 2 ⋅ |clampedHalfCorner|)</code>.
1. Let |singlePixelVectorFromStartToControlPoint| be the |vectorFromStartToControlPoint|, [=two-dimensional vector/normalize|normalized=].
1. Let <code>|strokeA|, |strokeB|</code> be the [=two-dimensional vector/perpendicular=] of |singlePixelVectorFromStartToControlPoint|.
1. Let |offset1| be [=vector between two points|the vector between=] |originalCornerStart| and |outerCorner|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] <code>|startInset| ⋅ |strokeA|</code>.
1. Let |offset2| be [=vector between two points|the vector between=] |outerCorner| and |originalCornerEnd|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] <code>|startInset| ⋅ |strokeB|</code>.
1. Let |offset3| be [=vector between two points|the vector between=] |originalCornerEnd| and |originalCornerCenter|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] <code>|endInset| ⋅ |strokeB|</code>.
1. Let |offset4| be [=vector between two points|the vector between=] |originalCornerCenter| and |originalCornerStart|, [=two-dimensional vector/normalize|normalized=] and [=two-dimensional vector/scale|scaled by=] <code>|endInset| ⋅ |strokeA|</code>.
1. Let |adjustedCornerStart| be |originalCornerStart|, [=point extended by vectors|extended by=] « |offset1|, |offset2| ».
1. Let |adjustedCornerEnd| be |originalCornerEnd|, [=point extended by vectors|extended by=] « |offset3|, |offset4| ».
1. Let |adjustedCornerCenter| be |originalCornerCenter|, [=point extended by vectors|extended by=] « |offset4|, |offset1| ».
1. Let |adjustedCornerOuter| be |originalCornerOuter|, [=point extended by vectors|extended by=] « |offset2|, |offset3| ».
1. Let |curveCenter| be |adjustedCornerOuter| if |curvature| is less than 0, |adjustedCornerCenter| otherwise.
1. Let |mapPointToCorner| be the following steps: given numbers |x| and |y|:
1. Let |v1| be [=vector between two points|the vector between=] |curveCenter| and |adjustedCornerEnd|, [=two-dimensional vector/scale|scaled by=] |x|.
1. Let |v2| be [=vector between two points|the vector between=] |curveCenter| and |adjustedCornerStart|, [=two-dimensional vector/scale|scaled by=] |y|.
1. Return the {{DOMPointReadOnly}} at <code>(|x|, |y|)</code>, [=point extended by vectors|extended by=] « |v1|, |v2| ».
1. Let |controlPoint| be the result of calling |mapPointToCorner| given |unitVectorFromStartToControlPoint|'s {{DOMPointReadOnly/y}} and <code>1 - |unitVectorFromStartToControlPoint|'s {{DOMPointReadOnly/x}}</code>.
1. Let |axisAlignedCornerStart| be the intersection between the lines <code>(|adjustedCornerStart|, |controlPoint|)</code> and <code>(|clipStart|, |clipOuter|)</code>. If the lines are parallel, let it be <code>adjustedCornerStart</code>.
1. Let |axisAlignedCornerEnd| be the intersection between the lines <code>(|adjustedCornerEnd|, |controlPoint|)</code> and <code>(|clipEnd|, |clipOuter|)</code>. If the lines are parallel, let it be <code>adjustedCornerEnd</code>.
1. Let |path| be a path, starting at |axisAlignedCornerStart|.
1. If |curvature| is -&infin;:
1. Extend |path| to |adjustedCornerOuter|.
1. Otherwise:
1. Let |K| be <code>0.5<sup>-abs(|curvature|)</sup></code>.
1. For every |T| between 0 and 1, in an [=implementation-approximated=] manner,
extend |path| to the result of calling |mapPointToCorner| given <code>|T|<sup>|K|</sup></code> and <code>(1 - |T|)<sup>|K|</sup></code>.
1. Extend |path| to |axisAlignedCornerEnd|.
1. Extend |path| to |clipOuter|.
1. Return |path|.
</div>

<wpt>
Expand Down Expand Up @@ -1624,7 +1669,7 @@ To compute the <dfn>normalized superellipse half corner</dfn> given a [=superell
: Otherwise
::
1. Let |k| be <code>0.5<sup>abs(|s|)</sup></code>.
1. Let |convexHalfCorner| be <code>0.5<sup>|k|</sup></code>.
1. Let |convexHalfCorner| be <code>0.5<sup>1/|k|</sup></code>.
1. If |s| is less than 0, return <code>1 - |convexHalfCorner|</code>.
1. Return |convexHalfCorner|.
</dl>
Expand Down