Clarify transform order of operations and API
Following discussion on #255 (closed) and !236 (merged), this issue is to clarify:
- Background: Order of operations for transformations
- Methods for applying transforms in the extensions API.
Background: Order of operations for transformations
Inline, nested, and composed transform representations
Following along with an example from the SVG spec, the following SVG element has an "inline" transform attribute with four parts:
<rect transform="translate(-10,-20) scale(2) rotate(45) translate(5,10)" [...] />
This is equivalent to the following nested set of transforms:
<g transform="translate(-10,-20)">
<g transform="scale(2)">
<g transform="rotate(45)">
<g transform="translate(5,10)">
<rect [...] />
</g>
</g>
</g>
</g>
It is also equivalent to the following form using a composed transform matrix, inline:
<rect transform="matrix(1.41421 1.41421 -1.41421 1.41421 -17.0711 1.2132)" [...] />
We can verify that these three are equivalent by applying them to three rect elements that are identical other than corner radius, and noting that they render identically both in Inkscape and in web browsers like Firefox, Chrome, and Safari:
Logical order of operations
Transformation order is not commutative, which is to say that (for example) a rotation applied before a translation is not equal in general to that same rotation and translation applied in the reverse order. It is critical to apply transforms in the correct order.
The SVG spec refers to these operations being applied to the coordinate system "in the order provided." That is to say, that the operations are:
- "translate(-10,-20)"
- "scale(2)"
- "rotate(45)"
- "translate(5,10)
With this perspective, the nested list transformations are applied in the order listed (outer group towards inner group) and inline transforms are also applied in the order listed.
A frequent source of confusion (e.g., 1, 2) is that it is often more intuitive to think of the transformations as being applied to the object (as opposed to being applied to the coordinate system). In this perspective, one generally views the transforms as being applied in the opposite order, such that the innermost-nested transform or rightmost inline transform is applied first.
As with other sign conventions found throughout mathematics, physics, and computer graphics (3, 4, 5), either perspective could be considered correct (whether or not it follows accepted conventions) if it is self consistent and produces the correct results.
Matrix representation
The abbreviated (truncated 3×3) matrices for the translate, scale, and rotate are given by:
-
translate(tr_x, tr_y)->((1.0, 0.0, tr_x), (0.0, 1.0, tr_y)) -
scale(sc_x, sc_y)->((sc_x, 0.0, 0.0), (0.0, sc_y, 0.0)) -
rotate(θ)->((cos θ, -sin θ, 0), (sin θ, cos θ, 0))
Thus the transformations for our example are given by the following, giving them additional matrix designations A, B, C, D.
-
A: "translate(-10,-20)" ->
((1.0, 0.0, -10), (0.0, 1.0, -20)) -
B: "scale(2)" ->
((2, 0.0, 0.0), (0.0, 2, 0.0)) -
C: "rotate(45)" ->
((0.707107, -0.707107, 0), (0.707107, 0.707107, 0)) -
D: "translate(5,10) ->
((1.0, 0.0, 5), (0.0, 1.0, 10))
We can compose these into a single transform matrix M with matrix multiplication: M = A @ B @ C @ D, where @ is the matrix multiplication operator. The result M is our previous composed matrix, ((1.41421, -1.41421, -17.0711), (1.41421 1.41421 1.2132))
Matrix multiplication order
Matrix multiplication is associative, so the multiplication order ((A @ B) @ C) @ D will yield the same result as A @ (B @ (C @ D)).
In the coordinate-system perspective, we wish to apply the matrices in the order that they appear. In pseduocode, that is like:
- current_Transform = 1 (where 1 is the identity matrix)
- current_Transform = current_Transform @ A
- current_Transform = current_Transform @ B
- current_Transform = current_Transform @ C
- current_Transform = current_Transform @ D
Here, we are performing sequential post-multiplication of the four transforms in the order that they occur.
In the object-system perspective, the transforms are applied from the innermost out, as follows:
- current_Transform = 1 (where 1 is the identity matrix)
- current_Transform = D @ current_Transform
- current_Transform = C @ current_Transform
- current_Transform = B @ current_Transform
- current_Transform = A @ current_Transform
In this last instance, we are performing sequential pre-multiplication of the four transforms, in such a way that it still composes the same result, M = A @ B @ C @ D.
Choosing the "correct" convention
IMO, it is important to remember that neither pre-multiplication nor post-multiplication is "correct" or "wrong" -- both are potentially valid strategies for manipulating transform matrices, and as demonstrated in the example here, either can be used to calculate composed transforms.
That said, we generally wish to pick and apply the coordinate-system perspective as per the SVG spec documentation, and use post-multiplication to apply transforms to an existing transform. We should generally encourage and make use of its strategy of evaluating transforms in the order that they occur in the document.
Methods for applying transforms in the extensions API.
Methods within the extensions API for creating and applying transforms currently include:
- Parsing transform strings, e.g.,
"translate(-10,-20) scale(2) rotate(45) translate(5,10)" - Parsing transforms from arguments, e.g.,
translate=(-10,-20), scale=(2), rotate=(45), translate=(5,10) -
add_matrix(),add_scale(),add_rotate()etc. - Initializing transforms as
Transform() - Direct matrix multiplication A * B, given by
__mul__ - In place multiplication A *= B, given by
__imul__
Parsing transform strings
Parsing transform strings, e.g., "translate(-10,-20) scale(2) rotate(45) translate(5,10)" should compose a matrix in the same order as multiplying A @ B @ C @ D.
Parsing transforms from arguments
Parsing transforms from arguments, e.g., translate=(-10,-20), scale=(2), rotate=(45), translate=(5,10) should compose a resulting matrix in the same order as multiplying A @ B @ C @ D. Argument order should already prevent ambiguity, at least in the Python versions that we support (#160 (closed)).
add_matrix(), add_scale(), add_rotate() etc.
While the basic idea of these is sound, I do recommend that we change the terminology (#253). One reason is that the terminology is misleading -- for example that add_matrix does not add a matrix, but multiplies a matrix. Another reason is to clarify how these are added.
One approach would be to use prepend_rotate()/append_rotate() as suggested by @snoyer. Another would be to use pre_rotate()/post_rotate(). It is reasonable also to have a non-explicit option (rotate()?) that defaults to post-multiplication.
Initializing transforms as Transform()
I believe that this works as expected, without ambiguity.
Direct matrix multiplication
I believe that this works as expected, without ambiguity.
Note: We might consider replacing the * operator with @ for matrix multiplication option. This might be considered as best practice, following PEP-465, but I think that's a reasonable thing to discuss separately.
In place multiplication
As we have seen, the exact meaning of A *= B can potentially be misinterpreted. For scalars, in-place multiplication (*=, __imul__) is "commutative," in the sense that either operand could be first in the multiplication. However for matrix multiplication, and by analogy with /=, we must interpret A *= B as being equivalent to A = A * B,
Some possible changes that we could consider:
- Remove the
*=operator entirely, without replacing it; let users explicitly multiply and assign. - Replace the
*=operator withA.pre_multiply(),A.post_multiply() - Replace the
*=operator with@=. This will provide a more clear indication that the operation is a non-commutative matrix-like operation.