Expand description
§Introduction
This crate serves to model and solve the following couple of problems:
-
How can we model all the possible rotations that can happen to a cube, that leave all its edges parallel to the same axes as before?
-
How many and which points must we place on this cube’s surface such that a) they are absolutely symmetric according to the previous rotations, and b) they have absolutely no mirror symmetry? In other words, how can we make sure that the cube will look the same from every possible orientation, while being different than its mirror image?
This is known as octahedral symmetry in the available literature. The linked article does contain an explanation, but this documentation aims to be a smidge more accessible.
§Initial approach and restrictions
Let there be a cube centred around the origin, with an edge-length of 6; the
criterion for the length will shortly become apparent. The vertices of this
cube are the eight points [±3, ±3, ±3]
.
Our first order of business, obviously, is to place one point on some face
of this cube. Its faces are the set of points whose maximum coördinate by
absolute value is equal to 3
. Thus, it immediately follows that all
coördinates must be between -3
and 3
inclusive.
The next thing to do is to ensure that, if the cube is reflected along one of its planes of symmetry, no point will coincide with itself. Thus, the question is raised: What is the effect that a reflection has on the coördinates of a point?
The answer: If the plane if symmetry is horizontal or vertical, it negates one of the coördinates. If it is diagonal, it swaps the values of two coördinates.
With that in mind, we immediately arrive at two fundamental restrictions: no coördinate can be zero, or else negating it would not change it; and no coördinate can be equal to another, or else swapping them would change nothing.
§Encoding the transforms
Next question: What do rotations do to a point’s coördinates? And the answer is that each even amount of reflections corresponds to a rotation1. In other words, each rotation can be decomposed to an even amount of negations and swaps.
This immediately answers both our questions. 3 coördinates can be ordered in 6 different ways in total, and their possible combinations of signs are 8. Thus, the possible transformations are 6×8 = 48 in total: 24 of them are rotations, and the other 24 are so-called improper rotations or rotoreflections.
Apart from that, for each complete transformation, each elementary reflection that comprises it needs only occur once, and they can always be examined in the same order. This permits us to encode, in just 1 bit, the application-or-not of each one of them, and thus fit all possible transformations within 6 bits. Of those, the rotations can be discerned by the fact that their binary representation will have an even amount of ones.
§Encoding coördinates
With regards to the encoding of the coördinates themselves, the choice of 6
for the size of the cube permits us to use integers for all 3 coördinates:
±1
, ±2
, ±3
. By choosing a specific order and signs, each of the 48
possible combinations can be found.
It is noteworthy that, if we assume one point to be a reference,
([1, 2, 3]
in our case,) every other point can be found by applying one
of those transformations –a different one each time– on it. This has the
extremely useful property that it permits us to encode transformations and
points in the exact same way. It also immediately separates those 48
possible points into two groups of 24: [1, 2, 3]
and its possible
rotations, and [3, 2, 1]
and its possible rotations. Each point belonging
to one of those groups can, with some suitable rotation, be made to coincide
with any other point of the same group; however, it is impossible for a
rotation to make it coincide with a point from the other group. Thus, each
of those two groups can comprise the solution to our original question.
Of note: since each transformation retains the absolute values of the
coördinates of each point, their exact values are not important. The point
[-400, 0.5, 14]
can be considered to be in [-3, 1, 2]
and all operations
remain correct.
§Nomenclature
In the rest of the documentation, each point is called a
“CubeSurfacePoint
”, and each set of operations on it (swaps and/or sign
flips) is called a “Transformation” or a “Rotation
”. The point
[1, 2, 3]
as also commonly called a Reference Point, because we judge all
transformations relative to it.
In the documentation, the two possible groups of 24 points are called Geometric Groups. They are sometimes distinguished into the Reference Geometric Group, ie the Point of Reference and the points in the same group as it, and the Opposite Geometric Group, ie the mirror image of the Reference Geometric Group.
The afore-mentioned reflections to which each of the 6 bits corresponds (ie, swapping the value of two coördinates or negating one of them) are collectively referred to as Elementary Reflections.
§Using the crate
Let there be three points on a cube, x
, y
, and z
.
let x = NegThreePosOnePosTwo;
let y = PosTwoPosThreeNegOne;
let z = PosThreePosOneNegTwo;
We eventually realise that x
’s actual orientation is different:
let x_actual = PosThreePosTwoPosOne;
We want our cube to be rotated in such a way, that x
ends up coïnciding
with x_actual
. That means that each of those points has to be rotated in
the exact same way.
To calculate the rotation, it suffices to perform one division:
rotation = x_actual / x
.
Afterwards, the operations r * x
, r * y
, and r * z
give us the results
we want. Please note that the operation (x_actual / x) * x
gives just
x_actual
, exactly as one would suppose from the notation.
let rotation = x_actual / x;
Here is the complete code, including validation of results:
let x = NegThreePosOnePosTwo;
let y = PosTwoPosThreeNegOne;
let z = PosThreePosOneNegTwo;
let x_actual = PosThreePosTwoPosOne;
let rotation = x_actual / x;
assert_eq!(rotation * x, PosThreePosTwoPosOne);
assert_eq!(rotation * y, NegTwoNegOnePosThree);
assert_eq!(rotation * z, NegThreeNegTwoPosOne);
§Implementation details
This crate was implemented with the following criteria, in descending order of importance:
- Correct structuring of API
- Serving as a proof-of-concept
- Needing as little memory as possible (RAM + program memory)
- Performance
Performance is last as, for the use-case for which this crate was implemented, those calculations are far outside of the critical path.
§Safety and panics
Despite this code using unsafe
internally, running cargo build
successfully is enough to guarantee the complete absence of panics or
undefined behaviour –henceforth “UB”– in this code. This is thanks to the
following properties:
- All functions are pure, ie stateless: this permits them to all be
const
. Additionally, no function has more than 2304 possible inputs. This means that all of them can be checked exhaustively in a relatively short amount of time, even undermiri
. - All panics/unsafety in this crate have been corralled into one function,
unreachable_semichecked
. In debug mode, it panics; in release mode, it’s UB; and it is the only function in the entire code-base that is permitted to do either of those things. As a result, as long as it remains indeed unreachable, the entire code-base is free from both panics and UB. - Each function has a corresponding one that operates using a Look-Up Table—
henceforth “LUT”. The LUT is populated at compile-time by calling the
corresponding
const
function iteratively, and exhaustively. This means that every function ends up being called with every possible input during compilation: in other words, every possible code-path gets activated at compile-time. (A funny result of this is that the code builds faster with--release
than without it.) - As a combination of the above, any possible code-path leading to the
unreachable_semichecked
function will indeed call it during compile-time, resulting in a compilation error as long as debug assertions are enabled. Thus, the absence of compilation errors suffices to prove the complete absence of panics or UB in the entire code-base. - Lastly, as a bonus: The test suite is so comprehensive that, as long as it is intact, even a purposeful sabotage of the crate would struggle to cause serious damage without test-breakage.
That said, this crate has also been annotated with the following attribute:
#![cfg_attr(debug_assertions, forbid(unsafe_code))]
Thus, any down-stream user that prefers to forbid unsafe
code entirely can
do so by including the following snippet in their Cargo.toml
file:
[profile.dev.package.cube-rotations]
debug-assertions = true
This, of course, opens the door for some missed optimisations. If there is any way to achieve both of those things, either through regular compiler optimisations or through PGO, that has not been investigated as of yet.
§Look-up tables and proper rotations
The most basic and most broadly applicable data-types contained herein are
CubeSurfacePoint
and Rotation
. They can be used to model all
relevant operations, but offer no particular guarantees.
Each of those two data-types is further split into two equally-sized,
compementary sub-sets, each offering a particular geometric guarantee.
Further, all of those 3 point-types also have wrapper data-types that
operate using look-up tables. In turn, all 7 of the point data-types are
generalised in the OctahedrallySymmetricPoint
trait.
The full list is as follows:
ProperRotation
: A subset ofRotation
. Models a proper rotation, ie one that can happen in the real world.ImproperRotation
: A subset ofRotation
that models a rotoreflection. Complementary toProperRotation
.ReferenceGroupPoint
: A subset ofCubeSurfacePoint
. Models a point in space that can be made to coincide with the Reference Point using just one rotation.OppositeGroupPoint
: A subset ofCubeSurfacePoint
. Models a point in space that can be made to coincide with the Reference Point using just one rotoreflection. Complementary toReferenceGroupPoint
.luts::CubeSurfacePoint::<false>
: As per the basicCubeSurfacePoint
, but uses LUTs. Each LUT is at most 48 bytes in length, but some operations might need multiple look-ups.luts::CubeSurfacePoint::<true>
: As per the basicCubeSurfacePoint
, but uses LUTs. Each LUT is up to 2304 bytes in length, and each operation is guaranteed to consist of a single look-up.luts::ReferenceGroupPoint
: As per the basicReferenceGroupPoint
, but uses LUTs. Each LUT is up to 576 bytes in length, and each operation is guaranteed to consist of a single look-up.luts::OppositeGroupPoint
: As per the basicOppositeGroupPoint
, but uses LUTs. Each LUT is up to 576 bytes in length, and each operation is guaranteed to consist of a single look-up.OctahedrallySymmetricPoint
: A trait that generalises the operation of all 7 afore-mentioned point data-types.
The Geometric-Group-specific data-types of the luts
module only operate
using big LUTs, for reasons further analysed in their documentation.
This is a general property of geometry, irrespective of amount of dimensions or possibly even Euclideanness. ↩
Modules§
- luts
- A module containing wrapper data-types whose calculations are performed using Look-Up Tables.
Structs§
- Improper
Rotation - As per a
Rotation
, but one that specifically needs at least one reflection. - Proper
Rotation - As per a
Rotation
, but one that specifically needs no reflections. - Rotation
- A data-type that describes, within 6 bits, the transformation from one
CubeSurfacePoint
to another.
Enums§
- Cube
Surface Point - Encodes the 48 possible points in space whose coördinates, in
ascending order of absolute value, are equal to
[1, 2, 3]
. - Direction
- Describes towards which direction the face of a cube is oriented.
- Opposite
Group Point - The set of
CubeSurfacePoint
s that can be made to coincide with the Reference Point using just one improper rotation. - Reference
Group Point - The set of
CubeSurfacePoint
s that can be made to coincide with the Reference Point using just one proper rotation.
Traits§
- Octahedrally
Symmetric Point - A trait that groups the common behaviour of all possible data-types that represent points on the surface of a cube.