2024-02-25 16:06:54

Hello everyone,

I have a very specific problem that I've been facing and would like to resolve.
I found a solution last night, however, it's too costly. But before that, let me explain the problem.

I'm writing a 3D box class for my game engine, and I'm having trouble rotating it.
What I actually need is an OBB, not an AABB. I'm trying to represent it with a central point and 3 rays that represent its width, length, and height, and a quaternion for its orientation. A class like this:

class Box3D {
    Vector3D position; // It's the center of the box.
    Vector3D alf; // The 3 radius...
    Quaternion orientation; // The orientation of the box...
};

However, whenever I misalign it from the axes with some rotation, some axis always distorts when I retrieve the min and max properties. But it returns to normal when it is aligned again to the axes. I can imagine why this happens, however, I couldn't find a cheap solution. What I've been doing was this:

void getMinMax(Vector3D& tMin, Vector3D& tMax) {
    tMin = position - alf;
    tMax = position + alf;
}

This works perfectly when it is aligned to the axes. Maybe the problem is also in my rotation method...

void rotate(const Vector3D& origin, const Quaternion& q) {
    orientation = orientation * q;
    orientation.normalize();
    position -= origin;
    position = quaternion_vector_rotate(q, position) + origin;
    alf = quaternion_vector_rotate(q, alf);
}

No matter what I do, in this model, the box always distorts on some axis, for example, if the x-axis of the box has a radius of 5, and I rotate it by 45 degrees, this axis will end up as 0, flattening the box. I understand that this happens due to the alignment of 2 vertices, however, there are other vertices of the box that would avoid this. Thinking now, maybe the cross product can help me somehow, although I haven't figured out how yet.
My solution that I found yesterday:
If I calculate all 8 vertices of the box in the object construction, I need to rotate all of them, however, I can perfectly retrieve the min and max properties, iterating through all of them and looking for the farthest ones in opposite directions.
This works, however, it consumes a lot of memory and is slow. I would like to know if anyone has any different ideas. Below I leave my test code, as well as the output. I tried to convert it to Python quickly, but the result wasn't good enough. So I send the original, even.

#include <iostream>
#include<sstream>

using namespace std;

class box3d{
public:
vector3d pos;
vector3d alf;
vector<vector3d> vertices;
quaternion orientation;
 box(const vector3d& pos, const vector3d& alf, const quaternion& orientation){
this->pos=pos;
this->alf=alf;
this->orientation=orientation;

generateVertices();
}
string toString(){
stringstream ss;
ss<<fixed;
ss.precision(2);
vector3d m1, m2;
string axis="xyz";
getMinMax(m1, m2);
ss<<"min="<<m1<<endl;
ss<<"max="<<m2<<endl;
for(uint32 i=0; i<3; i++){
ss<<axis[i]<<"="<<m1[i]<<", "<<m2[i]<<endl;
}
return ss.str();
}

void rotate(const vector3d& origin, const quaternion& q){
orientation=orientation*q;
orientation.normalize();
pos-=origin;
pos=quaternion_vector_rotate(q, pos)+origin;
for(auto& it : vertices){
it-=origin;
it=quaternion_vector_rotate(q, it)+origin;
}
}
void getMinMax(vector3d& m1, vector3d& m2){
m1=m2=pos;
for(auto& it : vertices){
for(uint32 i=0; i<3; i++){
if(it[i]<m1[i])m1[i]=it[i];
else if(it[i]>m2[i])m2[i]=it[i];
}
}
alf=(m2-m1)*0.5f;
}
void generateVertices(){
vertices.clear();
    vector3d xAxis(alf.x, 0, 0);
    vector3d yAxis(0, alf.y, 0);
    vector3d zAxis(0, 0, alf.z);

    vertices.push_back(vector3d(pos.x - alf.x, pos.y - alf.y, pos.z - alf.z));
    vertices.push_back(vector3d(pos.x + alf.x, pos.y - alf.y, pos.z - alf.z));
    vertices.push_back(vector3d(pos.x + alf.x, pos.y + alf.y, pos.z - alf.z));
    vertices.push_back(vector3d(pos.x - alf.x, pos.y + alf.y, pos.z - alf.z));
    vertices.push_back(vector3d(pos.x - alf.x, pos.y - alf.y, pos.z + alf.z));
    vertices.push_back(vector3d(pos.x + alf.x, pos.y - alf.y, pos.z + alf.z));
    vertices.push_back(vector3d(pos.x + alf.x, pos.y + alf.y, pos.z + alf.z));
    vertices.push_back(vector3d(pos.x - alf.x, pos.y + alf.y, pos.z + alf.z));
}
};

int main(){

box3d b({10,10,10}, {5,5,5}, quaternion_from_euler_angles(0,0,0));
quaternion orientation=quaternion_from_euler_angles(0,0,45);
vector3d pt={10,10,10};

for(uint32 i=0; i<8; i++){
cout<<"Iteration "<<(i+1)<<endl;
b.rotate(pt, orientation);
cout<<b.toString()<<endl;
}
return 0;
}

Below is the exit. Note that I only entered up to the second iteration, because in the
others the values are repeated, as expected.

 Iteration 1
 min=2.93, 2.93, 5.00
max=17.07, 17.07, 15.00
x=2.93, 17.07
y=2.93, 17.07
z=5.00, 15.00

 Iteration 2
 min=5.00, 5.00, 5.00
max=15.00, 15.00, 15.00
x=5.00, 15.00
y=5.00, 15.00
z=5.00, 15.00

Thank you very much for any tips that help optimize this. I would like to
get rid of the vertex list, but I don't know if it's possible.

2024-02-25 19:43:34

Get a library if you're going to insist on this.  Bullet works in C++ and is a full physics engine.  I think there might be at most 2 people here who can help you, I'm not one of them, and if this is a game for the blind you'll never be able to represent it well in audio.

Optimizing these algorithms is not simple.  In particular "iterate over all vertices" is quite possibly how larger things do it, but optimized by hand with simd or with some weird memory layout or whatever.  I don't think you can rotate a vector containing the dimensions though.  When I do look into this stuff generally I find that it's arbitrary vertices and the box is just a simpler case of arbitrary convex hulls and they do, yes, iterate everything.

My Blog
Twitter: @ajhicks1992

2024-02-25 22:52:56 (edited by Ethin 2024-02-25 22:54:25)

I might be misunderstanding something but here's my take on this (I'm assuming this class is your attempt at implementing an OBB) but here goes.
Your noticing distortion because the method your using to calculate the min/max bounds doesn't account for the rotation effect on the boxes relative dimensions to the world axes. Your approach currently assumes that the boxes half-extents remain constant and aligned with the world axes, which isn't the case once the box is rotated. When you rotate an OBB, both it's orientation and the projection of it's dimensions along the world axes change. Thus, you need to consider how the boxes dimensions project onto each of the world axes after rotation, which can't be achieved by just adding or subtracting the half-extent vector from the boxes position because the half-extent vector itself needs to be rotated to represent the boxes dimensions correctly in it's new orientation. You could do this manually, by using an orientation quaternian and transforming the boxes local axes by that quaternian, calculating the projection of the boxes dimensions onto the world axes, and then determining the min/max bounds along each axis from those projections, but as 2 said it would be far more efficient to just use a physics engine to do this for you since I guarantee that they're fully optimized. But if you do want to implement your own code (which would be good for learning/educational purposes), I'd strongly suggest using a library like glm, eigen, armadillo, or some other math library that has well-optimized mathematical routines that can somewhat make this faster for you.

"On two occasions I have been asked [by members of Parliament!]: 'Pray, Mr. Babbage, if you put into the machine wrong figures, will the right answers come out ?' I am not able rightly to apprehend the kind of confusion of ideas that could provoke such a question."    — Charles Babbage.
My Github

2024-02-26 00:25:50

@1. I understand your point. At the moment, I'm unable to use such a library due to a few reasons, including understanding how a game engine works, as it's the focus of my college thesis.
After nearly 18 months of work, I managed to get a basic functional version. However, I'm currently in the midst of a code refactoring, and I've decided to address this issue once and for all. (Spoiler alert: With @2's tip, I managed to solve it. I'll detail more below.)
Once it's stable enough, I'll delve into deeper optimizations, like exploring the possibility of SIMD. I've never used it before, but I'm grateful for the reminder of its existence and have made a note for future studies. I'm not optimizing in this way now because I want to see the real impact of the two versions of my engine.
And once again, you're right; the box can be a very simple convex shell. But for now, I want to focus on simple things. I tried to implement the GJK algorithm once, but it was a resounding failure. Not because of the algorithm itself, but because of the other algorithm to extract collision information (EPA). So, that's for later. And yes, I realized that I can't rotate the vector of the box's extensions. Initially, I thought I could do this because I rotate the capsule's axis, but I forgot the detail that the capsule's axis is a normalized vector.
With @2's tip on axis projection, I managed to come up with something that has at least worked so far.
I start by defining vectors for the 3 global axes. Then, I iterate 8 times to calculate each of the vertices of an AABB based on its extension. Then I project each extension onto the local axis. Finally, I rotate the vertex based on the OBB's orientation and convert it to a global point by adding the OBB's central point. This helped me avoid needing to maintain a list of 8 vertices all the time, but it still has the disadvantage of needing to be recalculated every time the box rotates. Below, I leave the method I've developed so far. It's still quite rough and will need a lot of work, but now I have a functional foundation to build upon.

void obbGetVertices(vector<vector3d>& points){

vector<vector3d> localAxis={
{1,0,0},
{0,1,0},
{0,0,1}
};

for(uint32 i=0; i<8; i++){
vector3d pt;
for(uint32 i1=0; i1<3; i1++){
                float offset = ((i & (1 << i1)) ? alf[i1] : -alf[i1]);
pt+=localAxis[i1]*offset;
}
pt=quaternion_vector_rotate(orientation, pt)+pos;
points.push_back(pt);
}
}

2024-02-26 01:10:15

You should benchmark x before saying x is too slow.  Also, you're headed for some hard times if you decided to make 3D game engines your thesis and don't have enough vision to debug it.  Been here, done this though outside college, wouldn't wish it on my worst enemy.  You can sort of get an idea by looking at the coordinates if you are very very very good at visualizing.  If you are going to try to write a full 3D physics engine that supports moving and all that, god help you because we certainly can't.

Note that modern compilers handle funny bit tricks for you and that you are better off not doing them yourself.  It makes the code less readable for no gain.  If you are going to do such things, set up to measure first.  In C++ I have used google/benchmark, which was pretty easy.

Your code there probably runs in under 20 cycles.  I'd say it may even get under 10 if the compiler is smart enough but that's starting to push the limits.  I don't know what the minimum cycle count for quaternions is and it depends slightly on processor, but I know that on modern X86 4x4 matrix ops can't get below 10 or so when done individually and compiling for SSE2 (you can do AVX instead but AVX2 isn't universal yet and AVX512 is very rare on home machines).  An algorithm like this done simply with proper layout in memory and no additional work on your part can probably do 4 or 5 of these in 20 cycles.  We get 1 billion a second conservatively, 20*60=1200, 1e9/1200 is around 800000, and at more than one per execution...I'll leave that as an exercise to the reader.  Still, most modern things offload to the GPU because there is a limit to how far the CPU can be pushed, and it is better to perform what you can over there.

Your intuitions around this stuff aren't, I'm not going to say they're wrong, but modern architectures and modern compilers make simple stupid code work in ways you'd not expect.  Often the assembly doesn't even look at all like your code.  For example: latency of multiplication is 1 cycle, throughput is often 0.5 instead, don't forget to mind the pipelining when picking instruction order.  Also the execution ports.  There's this thing I see from experienced (experienced as in years of C/C++, not me typoing inexperienced) programmers where they look at their C/C++/Rust code and go yeah this is slow, and I look at it and go yeah this could be fast because the compiler might do these three things and wtf why aren't you measuring before complaining?  You can build up some intuitions if you spend a long time specifically working to make code fast, but it's just... a lot.  "My code runs in the order I wrote it" is an abstraction: the actual statement is "the observable side effects of this code on this thread are as if it ran in order, only with respect to other code on this thread in the same program".  Measure measure measure.  I do.  I just know what to lean on to try to do better.  But the benchmark always comes first if you think performance matters.

My Blog
Twitter: @ajhicks1992

2024-02-26 14:31:39

Ok, that will be very helpful. I wasn't aware of that library. Next weekend I'll try to implement some tests with it, and let's see the results. Thanks for the tips! I'll post the results here when I have them.

2024-02-26 20:49:55

@5 I forgot to comment on debugging in the previous post, so here we go.
Since I can't rely on graphical debugging, I debug by observing the coordinates themselves.
First, I decide how the test should happen, based on something I know, and the expected behavior. Then, I track positions, and anything else that needs logging.
So, based on the log, I follow the trajectory and validate the test. It's complicated and laborious, but it works.
Of course, when things get big, I'll have hundreds of objects everywhere, and I'm still looking for a better method. All I can imagine at the moment is isolating the problem and testing it individually to find the cause.
I need to rewrite some functions here now to deal with obbs and other colliding shapes, and I had a few free hours today, and I worked on the collision between a sphere and an obb. I'll show you how I tested it below.
Basically, my test scenario is as follows:
I have a box, with measurements of 5 for each side, note that these are half measures here. It was rotated on the y-axis by 45 degrees, making a sort of ramp on the X Z axes.
Then, I created a sphere with a radius of 2.5 positioned to the left of the box.
This sphere, with each iteration, moves one unit on the x-axis, and at a certain point, it should start colliding with the box and climb it until it reaches the top, which is one of the corners of the box. Since there's no gravity or anything here, when it reaches the top, it will continue moving on the x-axis until the end of the test.
How do I know the sphere is at the right height? At the beginning of the test, I print both shapes, and in the case of the box, I check the max value, which would be the upper corner of the aabb... So, by taking the z-axis and adding it to the radius of the sphere, I can have the final height that the center of the sphere should be.
In the test, I have two pieces of information separated by lines: pos: is the position of the sphere, and the other line refers to the collision normal, when it occurs, the minimum translation vector to separate the objects, and the depth the sphere penetrated into the obb.
I still need to test many other scenarios, but this is a good starting point, given that I managed to solve the box problem yesterday.

int main() {
    G_STARTER gst;//Configure log and etc...

const vector3d pt={10.0f, 10.0f, 2.0f};//Center of obb...
    Box3d b(pt, {5, 5, 5});
    b.rotate(pt, quaternion_from_euler_angles(0, 45, 0));

    _GINFO("Printing the OBB...\n{}", b.toString());

    Sphere3d s({-2.5f, 10, 2.5f}, 2.5f);
_GINFO("Printing the Sphere...\n{}", s.toString());

    while (s.position.x < 30.0f) {
        s.translate({1.0f, 0.0f, 0.0f});

//Get the closest point from OBB to Sphere...
        vector3d closestPoint = b.getClosestPoint(s.position);
        vector3d d = closestPoint - s.position;
        float dist = vector3d::dotProduct(d, d);

        if (dist <= (s.radius * s.radius)) {
            vector3d normal = vector3d::normalize(d);
normal.inverse();
            float depth =fabs(s.radius-sqrt(dist));
            vector3d mtv = normal * depth;
_GINFO("n={}, mtv={}, d={:.2f}", normal, mtv, depth);
            s.translate(mtv);
        }

        _GINFO("pos={}", s.position);
    }

    return 0;
}

The output...

 Printing the OBB...
Type=2(Caixa), ALF=5.00, 5.00, 5.00
Position=10.00, 10.00, 2.00
min=2.93, 5.00, -5.07
max=17.07, 15.00, 9.07

 Printing the Sphere...
Type=1(Esfera), Raio=2.50
Position=-2.50, 10.00, 2.50
AABB={
X=-5.00, 0.00
Y=7.50, 12.50
Z=0.00, 5.00
}
 pos=-1.50, 10.00, 2.50
 pos=-0.50, 10.00, 2.50
 n=-0.98, -0.00, 0.20, mtv=-0.02, -0.00, 0.00, d=0.02
 pos=0.48, 10.00, 2.50
 n=-0.94, -0.00, 0.33, mtv=-0.91, -0.00, 0.32, d=0.97
 pos=0.57, 10.00, 2.82
 n=-0.86, -0.00, 0.52, mtv=-0.78, -0.00, 0.47, d=0.91
 pos=0.79, 10.00, 3.29
 n=-0.71, -0.00, 0.71, mtv=-0.55, -0.00, 0.55, d=0.78
 pos=1.24, 10.00, 3.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=1.74, 10.00, 4.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=2.24, 10.00, 4.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=2.74, 10.00, 5.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=3.24, 10.00, 5.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=3.74, 10.00, 6.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=4.24, 10.00, 6.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=4.74, 10.00, 7.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=5.24, 10.00, 7.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=5.74, 10.00, 8.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=6.24, 10.00, 8.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=6.74, 10.00, 9.34
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=7.24, 10.00, 9.84
 n=-0.71, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=7.74, 10.00, 10.34
 n=-0.70, -0.00, 0.71, mtv=-0.50, -0.00, 0.50, d=0.71
 pos=8.24, 10.00, 10.85
 n=-0.39, -0.00, 0.92, mtv=-0.22, -0.00, 0.52, d=0.57
 pos=9.01, 10.00, 11.37
 n=0.01, -0.00, 1.00, mtv=0.00, -0.00, 0.20, d=0.20
 pos=10.02, 10.00, 11.57
...Continue...
 pos=30.02, 10.00, 11.57

2024-02-26 21:11:10

I know all of this.  Like absolutely every audiogame dev ever, I have considered what it would take to write a 3D engine or to use off-the-shelf libraries from the sighted which allow 100% free rotation and arbitrary shapes.  You will, however, produce an intrinsically unplayable game even if you get it working, and I promise that your idea of somehow breaking bugs down doesn't work very well in practice.  Also, any randomness anywhere or even slight changes to fp math ruin determinism.  It's not like I haven't thought of all of this before.  But you have zero way to visualize anything and are you really going to be able to look over 500 vertices, magically spot the problem, isolate the problem, and write a (presumably reasonably automated) test to prove it doesn't happen?  I doubt it.  Maybe with sighted help.  If you can somehow represent a scene that's more than a very small number of simple shapes in audio efficiently, please go make all the sighted games accessible with your magic solution.

Also just wait until NaN or Infinity slip in somewhere and ruin all your careful visualizing tests.  Or boundary conditions.  And you haven't even gotten to the fun stuff around spatial partitioning.

Like ok, I get it, it's interesting, by all means learn it.  But tying this to a college degree?  And you're not really going to even be able to use it for anything.  Sighted games are literally a million vertices at a time, audiogames haven't cracked the 3D problem not even for non-rotating AABBs.  So, what's the point other than having some fun?  Why in the world would you tie graduating from college to this?  It's your mistake to make, but I can promise that you're going to hate yourself 6 months from now when you're trying to memorize all these vertices to figure out wtf happened and also every run, something different happens because you changed a + b + c to a + (b + c) somewhere.  Yes really, that does change the result, no joke.  Or, god help you, using a different compiler that decides not to use the X86 80-bit fp registers.

I mean I get that it's interesting but...idk man.  Talk about signing yourself up for hard mode by tying your success to this.

My Blog
Twitter: @ajhicks1992

2024-03-18 13:27:42

Just casually decided to check out this form as I'm bored and came across this thread on a subject that I love to debate, so and despite being a little late to the party, I still decided to reply hoping that I can help.


As someone who's played around with these things, including SIMD optimizations, without any sight in the past, I do think that you're on the right track, but ditch the quaternion as it doesn't help here at all. All you need is a regular 4x4 transformation matrix, and I'll explain how to build it below.

If you take a close look at what a rotation matrix is, you will realize that it's comprised of 3 normalized perpendicular vectors, one for each axis, with the same rotation applied to them. This matrix can be obtained by extracting the world transformation's top-left 3x3 matrix inverted and transposed, multiplying those 3 vectors by half of the bounding box's dimensions, and appending the node's world transformation's 4th vector to it, resulting in a transformation matrix that turns a 2x2x2 axis-aligned cube centered at the origin into a bounding box with the desired dimensions properly positioned and oriented in the game world.

Oriented box to oriented box collision detection is just checking whether any of the vertices of one box are inside another, and then checking whether any of the edges of one of the boxes intersects one of the faces of the other when the first test fails. This requires computing the 8 vertices and 6 normals for each of the boxes. To compute the 8 vertices you just add all the actual and negated 8 permutations of the first 3 vectors of the box's transformation matrix computed above to the 4th vector of the box's transformation matrix with its 4th component set to 0.0. Finally the box's 6 normals, which you will need to compute the line-plane intersections, are the 3 vectors of the inverse transpose of the top-left 3x3 matrix of the node's world transformation matrix and its negated counterparts. You can also just multiply all the vertices and normals of the aforementioned 2x2x2 axis-aligned cube centered at the origin by these matrices since they're regular transformation matrices, but doing this the way I'm suggesting is much cheaper.

All the operations  mentioned above are very cheap in terms of computational resources especially with SIMD, and they take advantage of the computation of the inverse transpose of the top-left 3x3 matrix of the node's world transformation which you already use to compute the rotation of the 3D model's normals so you get that for free. On top of being very cheap operations that completely replace 28 matrix-vector multiplications (8 vertices and 6 normals for the two boxes), they also conserve memory by not requiring you to store all the vertices and normals for every oriented bounding box in memory thus potentially improving efficiency by making it easier on CPU cache, and don't require any expensive quaternion-vector multiplications either.

Beyond this point I assume that you already know that you need to perform vector projections to check whether a point is inside a box, and line-plane intersections to check whether an edge intersects a face, since this is already outside the scope of your question.

Feel free to ask for clarification or code examples if necessary, as I understand that explaining these things in plain English can be quite confusing, and will be checking out this forum a lot more often.

PS: I'm in no way advocating not to use quaternions where they make sense. Quaternions are very useful to store node rotations since they can be normalized and are thus very stable, but they don't help here at all.

2024-03-18 15:26:23

@9
Hello! You've actually arrived at the right time. Just this past weekend, I was refactoring a few more things and tried to implement an OBB OBB test, but I couldn't make any progress. This is the last test that is missing to complete the series. Currently, I have detection done for sphere vs sphere, box vs sphere, sphere vs capsule, capsule vs capsule, and capsule vs box. So, the only thing left is the box vs box test.
I would appreciate it if you could show me the algorithm you described in code to make it clearer, even if it's not too much trouble. I tried implementing the SAT a few months ago, but I couldn't give it much attention yet, so I ended up postponing it for later.
I can easily convert my quaternion into a 4x4 matrix, so that's not a problem. I won't focus too much on performance for now because I want to get the collision detection between boxes working first.
Feel free to ask me for code samples if you need them. I plan to open-source it later when it's stable. Below, I'll leave my geometry structure and how I expect to perform the test, just to give you an idea of what I'm trying to do. But don't restrict yourself to that; if I understand the concept of your algorithm, I can adapt it later to my format. Thank you very much for your help!

#ifndef GEOMETRICSHAPE_H
#define GEOMETRICSHAPE_H
namespace gpp {
enum GEOMETRICTYPES {
    GTYPE_DEFAULT = 0,
    GTYPE_SPHERE = 1,
    GTYPE_BOX = 2,
    GTYPE_CAPSULE = 4,
GTYPE_CYLINDER=8
};
class GeometricShape {
private:
    uint32 gtype;
public:
vector3d lastPosition;
    vector3d position;
    quaternion orientation;
public:
    GeometricShape(uint32 gtype, const vector3d& position = { 0.0f, 0.0f, 0.0f }, const quaternion& orientation = quaternion()) : gtype(gtype), position(position), orientation(orientation) {lastPosition=position;}
    virtual ~GeometricShape() {}
inline virtual     std::string toString() const {
        return "Type: " + std::to_string(gtype) + "\nPosition=" + position.toString() + "\nOrientation=" + orientation.toString();
    }
inline uint32 getType()const{return gtype;}
inline virtual void setLastPosition(const vector3d& lastPosition){this->lastPosition=lastPosition;}
inline vector3d getLastPosition()const{return this->lastPosition;}
inline virtual void setPosition(const vector3d& position){this->position=position;}
inline vector3d getPosition()const{return this->position;}
inline void setOrientation(const quaternion& q){this->orientation=q;}
inline quaternion getOrientation()const{return this->orientation;}
inline     virtual bool collidingPoint(const vector3d& pt) { return false; }
inline     virtual vector3d getClosestPoint(const vector3d& pt) { return position; }
inline     virtual void rotate(const quaternion& q) {}
inline     virtual void rotate(const vector3d& origin, const quaternion& q) {}
inline     virtual void translate(const vector3d& ts) {}
inline virtual void getAABB(vector3d& tMin, vector3d& tMax)const {tMin=position; tMax=position;}
};
}
#endif

This is my OBB class...

#ifndef BOX3D_H
#define BOX3D_H
namespace gpp {
class Box3d : public GeometricShape {
public:
vector3d alf;
Box3d(const vector3d& position={0.0f, 0.0f, 0.0f}, const vector3d& alf={0.0f, 0.0f, 0.0f}, const quaternion& orientation=quaternion());
    virtual ~Box3d();
virtual std::string toString() const;
virtual bool collidingPoint(const vector3d& pt);
virtual vector3d getClosestPoint(const vector3d& pt);
    virtual void rotate(const quaternion& q) ;
    virtual void rotate(const vector3d& origin, const quaternion& q) ;
    virtual void translate(const vector3d& ts) ;
    virtual void getAABB(vector3d& tMin, vector3d& tMax)const;
virtual void getLocalAxis(std::vector<vector3d>& axis);
virtual void getVertices(std::vector<vector3d>& vertices)const;
};
}
#endif

And this is my collision function.

bool collisionBoxBox(Box3d* b1, Box3d* b2, CollisionInfo* info){

//The algorithm here...

info->normal=collisionNormal;
info->point=collisionPoint;
info->depth=collisionDepth;
return true;
}

2024-03-19 19:23:18

Wasn't expecting being asked to show code for the collision detection itself, but in any case and since I love both linear algebra and analytic geometry, I decided to put something together in Python, because everybody understands Python and I don't code in it very often. The code passed all my tests, but I didn't write enough tests to make sure that it's absolutely bullet proof. It's arithmetically as optimized as I could think of, but it's not computationally optimized as otherwise I'd write it in Rust or some other lower level language.

During the process of writing this code I thought of a faster solution than checking for vertex containment and edge intersection. What I realized was that it was enough to find the closest points between one box's center and the edges of the other box, and if at least one of these points is inside the first box, then the test succeeds. This is still a very slow operation though, so should only be performed if the axis-aligned tests from a bounding volume hierarchy indicate a potential collision.

Feel free to ask for clarification if you don't understand something in the code since I didn't bother commenting it.

from math import pi, sin

class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    
    def __mul__(self, other):
        if isinstance(other, float):
            return Vector(self.x * other, self.y * other, self.z * other)
        return Vector(self.x * other.x, self.y * other.y, self.z * other.z)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    
    def __truediv__(self, other):
        if isinstance(other, float):
            return Vector(self.x / other, self.y / other, self.z / other)
        return Vector(self.x / other.x, self.y / other.y, self.z / other.z)
    
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    def dot(self, other):
        return self.x * other.x + self.y * other.y + self.z * other.z
    
    def cross(self, other):
        return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x)
    
    def length(self):
        return self.dot(self) ** 0.5

class Quaternion:
    def __init__(self, vec, angle, raw = False):
        if raw:
            self.vector = vec
            self.scalar = angle
            return
        mul = sin(angle / 360.0 * pi)
        len = vec.length()
        self.vector = vec / len * mul
        self.scalar = (1.0 - mul * mul) ** 0.5
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            conj = Quaternion(-self.vector, self.scalar, True)
            vec = Quaternion(other, 0.0, True)
            quat = self * vec * conj
            return quat.vector
        return Quaternion(self.vector.cross(other.vector) + self.vector * other.scalar + other.vector * self.scalar, self.scalar * other.scalar - self.vector.dot(other.vector), True)

class Matrix:
    def __init__(self, translation, rotation, scale):
        self.vec0 = rotation * Vector(scale.x, 0.0, 0.0)
        self.vec1 = rotation * Vector(0.0, scale.y, 0.0)
        self.vec2 = rotation * Vector(0.0, 0.0, scale.z)
        self.vec3 = translation
        self.norm0 = rotation * Vector(1.0, 0.0, 0.0)
        self.norm1 = rotation * Vector(0.0, 1.0, 0.0)
        self.norm2 = rotation * Vector(0.0, 0.0, 1.0)

class Ray:
    def __init__(self, origin, direction):
        self.origin = origin
        self.direction = direction
    
    def closest_point(self, point):
        if point == self.origin:
            return point
        point = point - self.origin
        len = self.direction.length()
        if len == 0.0:
            return self.origin
        norm = self.direction / len
        fac = norm.dot(point) / len
        if fac < 0.0:
            return self.origin
        if fac > 1.0:
            return self.origin + self.direction
        return self.origin + self.direction * fac

class Plane:
    def __init__(self, origin, normal):
        self.origin = origin
        self.normal = normal
    
    def distance_to_point(self, point):
        point = point - self.origin
        return point.dot(self.normal)

class Box:
    def __init__(self, transform):
        self.transform = transform
    
    def contains_point(self, point):
        point = point - self.transform.vec3
        face = Plane(self.transform.vec0, self.transform.norm0)
        dist = face.distance_to_point(point)
        if dist < self.transform.vec0.length() * -2.0 or dist > 0.0:
            return False
        face = Plane(self.transform.vec1, self.transform.norm1)
        dist = face.distance_to_point(point)
        if dist < self.transform.vec1.length() * -2.0 or dist > 0.0:
            return False
        face = Plane(self.transform.vec2, self.transform.norm2)
        dist = face.distance_to_point(point)
        if dist < self.transform.vec2.length() * -2.0 or dist > 0.0:
            return False
        return True
    
    def intersects_ray(self, ray):
        point = ray.closest_point(self.transform.vec3)
        return self.contains_point(point)
    
    def intersects_box(self, other):
        if self.contains_point(other.transform.vec3):
            return True
        o0 = other.transform.vec3 - other.transform.vec0 - other.transform.vec1 - other.transform.vec2
        o1 = other.transform.vec3 + other.transform.vec0 - other.transform.vec1 - other.transform.vec2
        edge = Ray(o0, o1 - o0)
        if self.intersects_ray(edge):
            return True
        o2 = other.transform.vec3 + other.transform.vec0 + other.transform.vec1 - other.transform.vec2
        edge = Ray(o1, o2 - o1)
        if self.intersects_ray(edge):
            return True
        o3 = other.transform.vec3 - other.transform.vec0 + other.transform.vec1 - other.transform.vec2
        edge = Ray(o2, o3 - o2)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o3, o0 - o3)
        if self.intersects_ray(edge):
            return True
        o4 = other.transform.vec3 - other.transform.vec0 - other.transform.vec1 + other.transform.vec2
        o5 = other.transform.vec3 + other.transform.vec0 - other.transform.vec1 + other.transform.vec2
        edge = Ray(o4, o5 - o4)
        if self.intersects_ray(edge):
            return True
        o6 = other.transform.vec3 + other.transform.vec0 + other.transform.vec1 + other.transform.vec2
        edge = Ray(o5, o6 - o5)
        if self.intersects_ray(edge):
            return True
        o7 = other.transform.vec3 - other.transform.vec0 + other.transform.vec1 + other.transform.vec2
        edge = Ray(o6, o7 - o6)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o7, o4 - o7)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o0, o4 - o0)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o1, o5 - o1)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o2, o6 - o2)
        if self.intersects_ray(edge):
            return True
        edge = Ray(o3, o7 - o3)
        if self.intersects_ray(edge):
            return True
        return False

def check_box_box_collision(box0, box1):
    return box0.intersects_box(box1) or box1.intersects_box(box0)

def test(name, actual, expected):
    print("%s: %s" % (name, "Passed" if actual == expected else "Failed"))

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion(Vector(0.0, 0.0, 1.0), 0.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Matrix(translation, rotation, scale)
box0 = Box(transform)
test("Same AABB", check_box_box_collision(box0, box0), True)
translation = Vector(0.75, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box1 = Box(transform)
test("Overlapping AABBs with intersecting center points", check_box_box_collision(box0, box1), True)
translation = Vector(-0.75, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box2 = Box(transform)
test("Overlapping AABBs without intersecting center points", check_box_box_collision(box1, box2), True)
translation = Vector(2.0, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box3 = Box(transform)
test("Touching AABBs", check_box_box_collision(box0, box3), True)
translation = Vector(3.0, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box4 = Box(transform)
test("Non-overlapping AABBs", check_box_box_collision(box0, box4), False)
translation = Vector(0.0, 0.0, 0.0)
scale = Vector(2.0, 2.0, 2.0)
transform = Matrix(translation, rotation, scale)
box5 = Box(transform)
test("Container and contained ABBs", check_box_box_collision(box0, box5), True)
translation = Vector(0.0, 0.0, 0.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Matrix(translation, rotation, scale)
box6 = Box(transform)
translation = Vector(4.0, 4.0, 0.0)
scale = Vector(4.0, 1.0, 1.0)
transform = Matrix(translation, rotation, scale)
box7 = Box(transform)
test("AABBs with overlapping ends", check_box_box_collision(box6, box7), True)

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Matrix(translation, rotation, scale)
box0 = Box(transform)
test("Same oriented box", check_box_box_collision(box0, box0), True)
translation = Vector(1.0, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box1 = Box(transform)
test("Overlapping oriented boxes with intersecting center points", check_box_box_collision(box0, box1), True)
translation = Vector(-1.0, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box2 = Box(transform)
test("Overlapping oriented boxes without intersecting center points", check_box_box_collision(box1, box2), True)
translation = Vector(3.0, 0.0, 0.0)
transform = Matrix(translation, rotation, scale)
box3 = Box(transform)
test("Non-overlapping oriented boxes", check_box_box_collision(box0, box3), False)
translation = Vector(0.0, 0.0, 0.0)
scale = Vector(2.0, 2.0, 2.0)
transform = Matrix(translation, rotation, scale)
box4 = Box(transform)
test("Container and contained oriented boxes", check_box_box_collision(box0, box4), True)
sincos = 2 ** 0.5 * 0.5
scale = Vector(1.0, 4.0, 1.0)
transform = Matrix(translation, rotation, scale)
box5 = Box(transform)
translation = Vector(4.0 * sincos, 4.0 * sincos, 0.0)
scale = Vector(4.0, 1.0, 1.0)
transform = Matrix(translation, rotation, scale)
box6 = Box(transform)
test("Oriented boxes with overlapping ends", check_box_box_collision(box5, box6), True)

2024-03-20 18:41:16

@11
Hello!
Okay, thank you very much for this example. Looking at it quickly, I think I managed to understand most of the things.
I'll have more free time over the weekend, and I'll convert it to C++, so maybe questions will arise!

2024-03-22 06:26:08

I found a false negative bug in the implementation that I posted earlier in the thread. It happens when two thin boxes cross in narrow angles without any of the vertices or central point being inside either box. This happens because I'm checking whether the closest point between the center of one box and the edges of the other is inside the first box, which fails here because that point happens to be outside due to the narrow angle.

The code that triggers the bug follows:

translation = Vector(-1.1, 0.0, 0.0)
rotation = Quaternion(Vector(0.0, 0.0, 1.0), -5.0)
scale = Vector(1.0, 32.0, 1.0)
transform = Matrix(translation, rotation, scale)
box7 = Box(transform)
translation = Vector(1.1, 0.0, 0.0)
rotation = Quaternion(Vector(0.0, 0.0, 1.0), 5.0)
transform = Matrix(translation, rotation, scale)
box8 = Box(transform)
test("Tall crossing oriented boxes", check_box_box_collision(box7, box8), True)
point = Vector(0.0, 3.5, 0.0)
print(box7.contains_point(point), box8.contains_point(point))

The above prints:

Tall crossing oriented boxes: Failed
True True

After reading a description of the Separating Axis Theorem that you mentioned above I decided to try implementing it myself but couldn't make it fast enough, so I returned to the implementation that I suggested in my first post to this thread: checking whether no vertices of one box are inside the other and no edges of one box crosses the other's faces. In this code I took a shortcut in the function that performs the ray casting tests and am reporting the far end of the ray when the test fails, because that far end corresponds to a vertex, so a check for containment tests whether both a face was hit or whether a vertex is inside the box in just one go. The reason why I didn't implement this in the first place is because a failed test goes through 144 ray casts (12 casts against 6 faces for the 2 boxes), and in my opinion there should be a faster way to accomplish this.

The code with tests follows:

import math

class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    
    def __mul__(self, other):
        if isinstance(other, float):
            return Vector(self.x * other, self.y * other, self.z * other)
        return Vector(self.x * other.x, self.y * other.y, self.z * other.z)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    
    def __truediv__(self, other):
        if isinstance(other, float):
            return Vector(self.x / other, self.y / other, self.z / other)
        return Vector(self.x / other.x, self.y / other.y, self.z / other.z)
    
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    def dot(self, other):
        return self.x * other.x + self.y * other.y + self.z * other.z
    
    def cross(self, other):
        return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x)
    
    def length(self):
        return math.sqrt(self.dot(self))

class Quaternion:
    def __init__(self, vector, scalar):
        self.vector = vector
        self.scalar = scalar
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            conj = Quaternion(-self.vector, self.scalar)
            vec = Quaternion(other, 0.0)
            quat = self * vec * conj
            return quat.vector
        return Quaternion(self.vector.cross(other.vector) + self.vector * other.scalar + other.vector * self.scalar, self.scalar * other.scalar - self.vector.dot(other.vector))
    
    @staticmethod
    def from_axis_angle(axis, angle):
        sin = math.sin(angle / 360.0 * math.pi)
        vec = axis/ axis.length()
        return Quaternion(vec * sin, math.sqrt(1.0 - sin * sin))

class Transform:
    def __init__(self, translation, rotation, scale):
        self.vec0 = rotation * Vector(scale.x, 0.0, 0.0)
        self.vec1 = rotation * Vector(0.0, scale.y, 0.0)
        self.vec2 = rotation * Vector(0.0, 0.0, scale.z)
        self.vec3 = rotation * translation
        self.norm0 = rotation * Vector(1.0, 0.0, 0.0)
        self.norm1 = rotation * Vector(0.0, 1.0, 0.0)
        self.norm2 = rotation * Vector(0.0, 0.0, 1.0)

class Ray:
    def __init__(self, origin, direction):
        self.origin = origin
        self.direction = direction

class Plane:
    def __init__(self, origin, normal):
        self.origin = origin
        self.normal = normal
    
    def distance_to_point(self, point):
        point = point - self.origin
        return point.dot(self.normal)

class Box:
    def __init__(self, transform):
        self.transform = transform
        self.position = transform.vec3
        self.width = transform.vec0.length() * 2.0
        self.height = transform.vec1.length() * 2.0
        self.depth = transform.vec2.length() * 2.0
        self.vertices = [
            transform.vec3 - transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 + transform.vec2
        ]
        self.edges = [
            Ray(self.vertices[0], self.vertices[1] - self.vertices[0]),
            Ray(self.vertices[1], self.vertices[3] - self.vertices[1]),
            Ray(self.vertices[3], self.vertices[2] - self.vertices[3]),
            Ray(self.vertices[2], self.vertices[0] - self.vertices[2]),
            Ray(self.vertices[4], self.vertices[5] - self.vertices[4]),
            Ray(self.vertices[5], self.vertices[7] - self.vertices[5]),
            Ray(self.vertices[6], self.vertices[7] - self.vertices[6]),
            Ray(self.vertices[6], self.vertices[4] - self.vertices[6]),
            Ray(self.vertices[0], self.vertices[4] - self.vertices[0]),
            Ray(self.vertices[1], self.vertices[5] - self.vertices[1]),
            Ray(self.vertices[2], self.vertices[6] - self.vertices[2]),
            Ray(self.vertices[3], self.vertices[7] - self.vertices[3]),
        ]
        self.faces = [
            Plane(transform.vec3 + transform.vec0, transform.norm0),
            Plane(transform.vec3 - transform.vec0, -transform.norm0),
            Plane(transform.vec3 + transform.vec1, transform.norm1),
            Plane(transform.vec3 - transform.vec1, -transform.norm1),
            Plane(transform.vec3 + transform.vec2, transform.norm2),
            Plane(transform.vec3 - transform.vec2, -transform.norm2)
        ]
    
    def contains_point(self, point):
        point = point - self.position
        face = Plane(self.transform.vec0, self.transform.norm0)
        dist = face.distance_to_point(point)
        if dist > epsilon or dist < -self.width - epsilon:
            return False
        face = Plane(self.transform.vec1, self.transform.norm1)
        dist = face.distance_to_point(point)
        if dist > epsilon or dist < -self.height - epsilon:
            return False
        face = Plane(self.transform.vec2, self.transform.norm2)
        dist = face.distance_to_point(point)
        if dist > epsilon or dist < -self.depth - epsilon:
            return False
        return True

def ray_hits_plane(ray, plane):
    if ray.direction.dot(plane.normal) >= 0.0:
        return ray.origin + ray.direction
    dist = plane.distance_to_point(ray.origin)
    if dist == 0.0:
        return ray.origin
    rate = ray.direction.dot(-plane.normal)
    fac = min(max(dist / rate, 0.0), 1.0)
    return ray.origin + ray.direction * fac

def ray_hits_box(ray, box):
    for face in box.faces:
        point = ray_hits_plane(ray, face)
        if box.contains_point(point):
            return True
    return False

def box_intersects_box(box0, box1):
    for index in range(0, 12):
        edge = box0.edges[index]
        if ray_hits_box(edge, box1):
            return True
        edge = box1.edges[index]
        if ray_hits_box(edge, box0):
            return True
    return False

def test(name, actual, expected):
    print("%s: %s" % (name, "Passed" if actual == expected else "Failed"))

epsilon = 1e-6

transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box0 = Box(transform)
test("Same box", box_intersects_box(box0, box0), True)
transform = Transform(Vector(0.0, 1.5, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box1 = Box(transform)
test("Intersecting boxes", box_intersects_box(box0, box1), True)
transform = Transform(Vector(0.0, 3.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box2 = Box(transform)
test("Separate boxes", box_intersects_box(box0, box2), False)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 0.0), Vector(1.0, 1.0, 1.0))
box3 = Box(transform)
test("Intersecting boxes forming a star", box_intersects_box(box0, box3), True)
transform = Transform(Vector(2.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box4 = Box(transform)
test("Boxes with faces touching", box_intersects_box(box0, box4), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box5 = Box(transform)
transform = Transform(Vector(0.0, 2.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(3.0, 1.0, 1.0))
box6 = Box(transform)
test("Tall and wide boxes forming a T", box_intersects_box(box5, box6), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 30.0), Vector(1.0, 4.0, 1.0))
box7 = Box(transform)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box8 = Box(transform)
test("Tall boxes forming an X", box_intersects_box(box7, box8), True)
transform = Transform(Vector(0.0, 0.0, 3.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box9 = Box(transform)
test("Skew tall boxes", box_intersects_box(box7, box9), False)
transform = Transform(Vector(-5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box10 = Box(transform)
transform = Transform(Vector(5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -45.0), Vector(1.0, 4.0, 1.0))
box11 = Box(transform)
test("Boxes forming a V with only edges touching", box_intersects_box(box10, box11), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(2.0, 2.0, 2.0))
box12 = Box(transform)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 30.0), Vector(1.0, 1.0, 1.0))
box13 = Box(transform)
test("Container and contained boxes", box_intersects_box(box12, box13), True)

2024-03-23 02:42:28

@12
So, I'm considering that the SAT algorithm seems simpler to implement, but I haven't managed to get a satisfactory version yet. On the other hand, GJK is more robust, although I haven't been able to implement it yet.
I've been having some difficulties integrating your collision method into my libraries for testing, but the correction you suggested seems to simplify things.
Additionally, I've implemented the method to check if a point is contained in an OBB that you provided, which I had forgotten to implement. You did it in a better way than I would have, lol.
Before trying to implement your suggestion, I'm thinking of testing another approach. The idea is to cast a ray from object 2 towards the center of object 1 and check if the distance is shorter than from the center of object 2 to its edge in the direction of the ray. What do you think of that?
Something like this:

origin = b2.position;
dir = b1.position - b2.position;
dir.normalize();
if (rayOBB(origin, dir, b1)) {
//Check the distance...
}

I created a function `rayOBB` that returns some information, such as the ray's entry point, the exit point and the distance to the entry and exit points. However, it is not bulletproof yet. Below is the implementation of the function, and note that `RayInfo` is just a class to store these four pieces of information.

    bool rayOBB(const vector3d& origin, const vector3d& dir, const vector3d& center, const vector3d& alf, const std::array<vector3d, 3>& axis, RayInfo* info){

        vector3d delta = center - origin;
        vector3d extents = alf;

        decimal tIn = -std::numeric_limits<decimal>::infinity();
        decimal tOut = std::numeric_limits<decimal>::infinity();

        for (int32 i = 0; i < 3; ++i)
        {
            decimal e = vector3d::dot(axis[i], delta);
            decimal f = vector3d::dot(dir, axis[i]);

            if (std::fabs(f) > 0.00001)
            {
                decimal t1 = (e - extents[i]) / f;
                decimal t2 = (e + extents[i]) / f;

                if (t1 > t2)
                    std::swap(t1, t2);

                if (t1 > tIn)
                    tIn = t1;

                if (t2 < tOut)
                    tOut = t2;

                if (tIn > tOut || tOut < 0)
                    return false;
            }
            else if (-e - extents[i] > 0 || -e + extents[i] < 0)
            {
                return false;
            }
        }

if(info!=NULL){
info->eDist=tIn;
info->ePoint = origin + dir * tIn;
info->oDist=tOut;
        info->oPoint = origin + dir * tOut;
}
        return true;
    }
}

Thanks for your help!

2024-03-25 01:42:04

Sorry about the late reply!

Your idea of casting a ray between the center points of the two boxes and checking whether there are no gaps between the back face of the source box and the front face of the destination box will result in false negatives, such as when you try to test for collisions between a wide and a tall box with their ends overlapping forming the shape of an L, as the straight line between the two center points exits one box before entering the other in this configuration even when there's overlap.

As for the GJK algorithm,I heard about it before but never paid much attention to it, however yesterday I actually decided to study it [1][2] and have a working implementation to share which is my interpretation of the algorithm and not a transcode of anyone else's implementation. Unfortunately I could not achieve 100% test coverage since there seems to always exist a case when the line segment between two Minkowski differences passes right through the origin, making it impossible to use the triple product to compute a perpendicular line in the direction of the origin, but also guaranteeing that the shapes are at least touching one another. I don't know whether this is a result of working with boxes, and if so whether it's possible to optimize the algorithm, and I can't verify this since the Minkowski difference cloud is very hard to visualize mentally, but this algorithm is really fast, passes all my tests, and can be used to test for collisions with any two convex shapes as long as the shape can provide a virtual / polymorphic function for finding support points in any direction.

import math

class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    def __repr__(self):
        return "[x: %.02f, y: %.02f, z: %.02f]" % (self.x, self.y, self.z)
    
    def __eq__(self, other):
        if (math.isnan(self.x) or math.isnan(self.y) or math.isnan(self.z)) and (math.isnan(other.x) or math.isnan(other.y) or math.isnan(other.z)):
            return True
        diff = self - other
        return abs(diff.x) < epsilon and abs(diff.y) < epsilon and abs(diff.z) < epsilon
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    
    def __mul__(self, other):
        if isinstance(other, float):
            return Vector(self.x * other, self.y * other, self.z * other)
        return Vector(self.x * other.x, self.y * other.y, self.z * other.z)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    
    def __truediv__(self, other):
        if isinstance(other, float):
            return Vector(self.x / other, self.y / other, self.z / other)
        return Vector(self.x / other.x, self.y / other.y, self.z / other.z)
    
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    def dot(self, other):
        return self.x * other.x + self.y * other.y + self.z * other.z
    
    def cross(self, other):
        return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x)
    
    def square_length(self):
        return self.dot(self)
    
    def length(self):
        return math.sqrt(self.square_length())

Vector.INVALID = Vector(math.nan, math.nan, math.nan)

class Quaternion:
    def __init__(self, vector, scalar):
        self.vector = vector
        self.scalar = scalar
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            conj = Quaternion(-self.vector, self.scalar)
            vec = Quaternion(other, 0.0)
            quat = self * vec * conj
            return quat.vector
        return Quaternion(self.vector.cross(other.vector) + self.vector * other.scalar + other.vector * self.scalar, self.scalar * other.scalar - self.vector.dot(other.vector))
    
    @staticmethod
    def from_axis_angle(axis, angle):
        sin = math.sin(angle / 360.0 * math.pi)
        vec = axis/ axis.length()
        return Quaternion(vec * sin, math.sqrt(1.0 - sin * sin))

class Transform:
    def __init__(self, translation, rotation, scale):
        self.vec0 = rotation * Vector(scale.x, 0.0, 0.0)
        self.vec1 = rotation * Vector(0.0, scale.y, 0.0)
        self.vec2 = rotation * Vector(0.0, 0.0, scale.z)
        self.vec3 = rotation * translation
        self.norm0 = rotation * Vector(1.0, 0.0, 0.0)
        self.norm1 = rotation * Vector(0.0, 1.0, 0.0)
        self.norm2 = rotation * Vector(0.0, 0.0, 1.0)

class Tetrahedron:
    def __init__(self):
        self.vertices = []
    
    def add_vertex(self, vertex):
        if len(self.vertices) < 4 and not vertex in self.vertices:
            self.vertices.append(vertex)
    
    def contains_origin(self):
        if len(self.vertices) < 4:
            return False
        for face in ((0, 1, 2, 3), (0, 1, 3, 2), (0, 2, 3, 1), (1, 2, 3, 0)):
            origin = self.vertices[face[0]]
            perp = (self.vertices[face[1]] - origin).cross(self.vertices[face[2]] - origin)
            perp = perp if perp.dot(self.vertices[face[3]] - origin) <= 0.0 else -perp
            if perp.dot(-origin) > epsilon:
                minlen = math.inf
                verts = []
                for vert in self.vertices:
                    sqlen = vert.square_length()
                    if sqlen < minlen:
                        minlen = sqlen
                        verts = [vert]
                    elif sqlen == minlen:
                        verts.append(vert)
                self.vertices = verts
                return False
        return True

class Box:
    def __init__(self, transform):
        self.position = transform.vec3
        self.vertices = (
            transform.vec3 - transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 + transform.vec2
        )
    
    def find_support_point(self, direction):
        if direction.square_length() == 0.0:
            raise Exception("Direction is null vector")
        best = None
        max = -math.inf
        for vertex in self.vertices:
            dot = vertex.dot(direction)
            if dot > max:
                best = vertex
                max = dot
        return best

def shape_intersects_shape(shape0, shape1):
    if shape1.position == shape0.position:
        return True
    dir = shape1.position - shape0.position
    point0 = shape0.find_support_point(-dir)
    point1 = shape1.find_support_point(dir)
    diff0 = point1 - point0
    point0 = shape0.find_support_point(dir)
    point1 = shape1.find_support_point(-dir)
    diff1 = point1 - point0
    if diff0.dot(diff1) > 0.0:
        return False
    dirs = []
    tet = Tetrahedron()
    tet.add_vertex(diff0)
    tet.add_vertex(diff1)
    while True:
        if len(dirs) >= 256:
            raise Exception("Too many iterations")
        dir = (diff1 - diff0).cross(-diff1).cross(diff1 - diff0)
        if dir.square_length() < epsilon:
            return True
        if dir in dirs:
            return False
        dirs.append(dir)
        point0 = shape0.find_support_point(dir)
        point1 = shape1.find_support_point(-dir)
        diff0 = diff1
        diff1 = point1 - point0
        tet.add_vertex(diff1)
        if tet.contains_origin():
            return True

def test(name, actual, expected):
    print("%s: %s" % (name, "Passed" if actual == expected else "Failed"))

epsilon = 1e-6

transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box0 = Box(transform)
test("Same box", shape_intersects_shape(box0, box0), True)
transform = Transform(Vector(0.5, 1.5, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box1 = Box(transform)
test("Intersecting boxes", shape_intersects_shape(box0, box1), True)
transform = Transform(Vector(0.0, 3.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box2 = Box(transform)
test("Separate boxes", shape_intersects_shape(box0, box2), False)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 0.0), Vector(1.0, 1.0, 1.0))
box3 = Box(transform)
test("Intersecting boxes forming a star", shape_intersects_shape(box0, box3), True)
transform = Transform(Vector(2.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box4 = Box(transform)
test("Boxes with faces touching", shape_intersects_shape(box0, box4), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box5 = Box(transform)
transform = Transform(Vector(0.0, 2.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(3.0, 1.0, 1.0))
box6 = Box(transform)
test("Tall and wide boxes forming a T", shape_intersects_shape(box5, box6), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 30.0), Vector(1.0, 4.0, 1.0))
box7 = Box(transform)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box8 = Box(transform)
test("Tall boxes forming an X", shape_intersects_shape(box7, box8), True)
transform = Transform(Vector(0.0, 0.0, 3.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box9 = Box(transform)
test("Skew tall boxes", shape_intersects_shape(box7, box9), False)
transform = Transform(Vector(-5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box10 = Box(transform)
transform = Transform(Vector(5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -45.0), Vector(1.0, 4.0, 1.0))
box11 = Box(transform)
test("Boxes forming a V with only edges touching", shape_intersects_shape(box10, box11), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(4.0, 4.0, 4.0))
box12 = Box(transform)
transform = Transform(Vector(0.5, 1.0, 0.0), Quaternion.from_axis_angle(Vector(1.5, 1.5, 1.0), 30.0), Vector(1.0, 1.0, 1.0))
box13 = Box(transform)
test("Container and contained boxes", shape_intersects_shape(box12, box13), True)

[1]: https://www.youtube.com/watch?v=ajv46BSqcK4
[2]: https://github.com/kroitor/gjk.c/blob/master/README.md#

2024-03-26 06:28:28

OK so there were a lot of things very wrong with the implementation in my previous comment. It did pass the tests, but some of the code never even ran when it should, and after investigating why I figured that it was far from being a proper GJK implementation. My test to verify whether a segment passes the origin was wrong, my test to verify whether the origin is contained in the simplex was very wrong, my response to when a simplex doesn't contain the origin was wrong, but I'd still like to understand how the code even worked, because I might have found an optimization given how little it had to compute in order to pass all the tests, either that or I was just very unlucky.

Below follows what I think is the correct implementation along with its output, with some comments since certain things might not be very obvious:

import math

class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    def __repr__(self):
        return "[x: %.02f, y: %.02f, z: %.02f]" % (self.x, self.y, self.z)
    
    def __eq__(self, other):
        if (math.isnan(self.x) or math.isnan(self.y) or math.isnan(self.z)) and (math.isnan(other.x) or math.isnan(other.y) or math.isnan(other.z)):
            return True
        diff = self - other
        return abs(diff.x) < epsilon and abs(diff.y) < epsilon and abs(diff.z) < epsilon
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    
    def __mul__(self, other):
        if isinstance(other, float):
            return Vector(self.x * other, self.y * other, self.z * other)
        return Vector(self.x * other.x, self.y * other.y, self.z * other.z)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    
    def __truediv__(self, other):
        if isinstance(other, float):
            return Vector(self.x / other, self.y / other, self.z / other)
        return Vector(self.x / other.x, self.y / other.y, self.z / other.z)
    
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    def dot(self, other):
        return self.x * other.x + self.y * other.y + self.z * other.z
    
    def cross(self, other):
        return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x)
    
    def square_length(self):
        return self.dot(self)
    
    def length(self):
        return math.sqrt(self.square_length())

Vector.ORIGIN = Vector(0.0, 0.0, 0.0)

class Quaternion:
    def __init__(self, vector, scalar):
        self.vector = vector
        self.scalar = scalar
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            conj = Quaternion(-self.vector, self.scalar)
            vec = Quaternion(other, 0.0)
            quat = self * vec * conj
            return quat.vector
        return Quaternion(self.vector.cross(other.vector) + self.vector * other.scalar + other.vector * self.scalar, self.scalar * other.scalar - self.vector.dot(other.vector))
    
    @staticmethod
    def from_axis_angle(axis, angle):
        sin = math.sin(angle / 360.0 * math.pi)
        vec = axis/ axis.length()
        return Quaternion(vec * sin, math.sqrt(1.0 - sin * sin))

class Transform:
    def __init__(self, translation, rotation, scale):
        self.vec0 = rotation * Vector(scale.x, 0.0, 0.0)
        self.vec1 = rotation * Vector(0.0, scale.y, 0.0)
        self.vec2 = rotation * Vector(0.0, 0.0, scale.z)
        self.vec3 = rotation * translation

class Tetrahedron:
    def __init__(self):
        self.vertices = []
        self.attempts = []
    
    def add_vertex(self, vertex):
        # Reject if a line segment between this and another vector in the simplex crosses the origin.
        if [x for x in filter(lambda x: x.cross(vertex) == Vector.ORIGIN, self.vertices)]:
            print("Failed to add vertex %s: Degenerate vertex already in simplex" % (vertex))
            return False
        # Reject if the line segment between this vertex and the last inseted vertex doesn't pass the origin.
        # Maybe perform this check against all the other segments?
        if self.vertices and vertex.dot(vertex - self.vertices[-1]) < 0.0:
            print("Failed to add vertex %s: Vertex doesn't pass origin" % (vertex))
            return False
        # Abort if we try to add more than 4 vertices by mistake.
        if len(self.vertices) >= 4:
            raise Exception("Too many vertices for simplex")
        self.vertices.append(vertex)
        # Clear all the vertices if the current simplex doesn't contain the origin.
        if len(self.vertices) == 4 and self.vertices in self.attempts:
            print("Failed to add vertex %s: Repeated simplex" % (vertex))
            self.vertices.clear()
            return False
        print("Successfully added vertex %s" % (vertex))
        return True
    
    def contains_origin(self):
        if len(self.vertices) < 4:
            return False
        # Loop over all the face index permutations, with the 4th index indicating each face's opposite vertex.
        for indices in ((0, 1, 2, 3), (0, 1, 3, 2), (0, 2, 3, 1), (1, 2, 3, 0)):
            # Find the perpendicular vector to the face's plane, which can be pointing either inside or outside.
            perp = (self.vertices[indices[1]] - self.vertices[indices[0]]).cross(self.vertices[indices[2]] - self.vertices[indices[0]])
            # Make sure that the vector perpendicular to the face points outwards using the opposite vertex as guide.
            if perp.dot(self.vertices[indices[0]] - self.vertices[indices[3]]) < 0.0:
                perp = -perp
            # Fail if the origin is in front of the face.
            if perp.dot(self.vertices[indices[0]]) < 0.0:
                self.attempts.append(self.vertices)
                self.vertices = []
                print("Origin  not in simplex")
                return False
        print("Origin in simplex")
        return True
    
    def suggest_direction(self):
        match len(self.vertices):
            case 1:
                # Return the direction to the origin.
                return -self.vertices[0]
            case 2:
                # Return a vector perpendicular to the line segment in the general direction of the origin.
                return (self.vertices[1] - self.vertices[0]).cross(-self.vertices[0]).cross(self.vertices[1] - self.vertices[0])
            case 3:
                # Return the vector perpendicular to the face in the general direction of the origin.
                perp = (self.vertices[1] - self.vertices[0]).cross(self.vertices[2] - self.vertices[0])
                return perp if perp.dot(self.vertices[0]) < 0.0 else -perp

class Box:
    def __init__(self, transform):
        self.position = transform.vec3
        self.vertices = (
            transform.vec3 - transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 - transform.vec2,
            transform.vec3 - transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 - transform.vec1 + transform.vec2,
            transform.vec3 - transform.vec0 + transform.vec1 + transform.vec2,
            transform.vec3 + transform.vec0 + transform.vec1 + transform.vec2
        )
    
    def find_support_point(self, direction):
        if direction.square_length() == 0.0:
            raise Exception("Direction is null vector")
        # Find the farthest point in a specific direction.
        best = None
        max = -math.inf
        for vertex in self.vertices:
            dot = vertex.dot(direction)
            if dot > max:
                best = vertex
                max = dot
        return best

def shape_intersects_shape(shape0, shape1):
    # Centroids overlapping definitely means collision.
    if shape1.position == shape0.position:
        print("Centroids overlap")
        return True
    tet = Tetrahedron()
    # Generate the first direction vector pointing at the other solid's centroid.
    dir = shape1.position - shape0.position
    point0 = shape0.find_support_point(-dir)
    point1 = shape1.find_support_point(dir)
    diff = point1 - point0
    tet.add_vertex(diff)
    while True:
        dir = tet.suggest_direction()
        # If no direction is suggested, indicating no simplex vertices, aim at the previously defined difference.
        if not dir:
            dir = diff
        point0 = shape0.find_support_point(-dir)
        point1 = shape1.find_support_point(dir)
        diff = point1 - point0
        # Succeed if the Minkowski difference returns the origin as that means that the solids are touching.
        if diff == Vector.ORIGIN:
            print("Degenerate case (solids touching)")
            return True
        # If a vertex fails to be added, that's the halting condition.
        if not tet.add_vertex(diff):
            return False
        # If the simplex contains the origin, then the solids are overlapping.
        if tet.contains_origin():
            return True

def test(name, actual, expected):
    print("%s: %s" % (name, "Passed" if actual == expected else "Failed"))

epsilon = 1e-3

transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box0 = Box(transform)
test("Same box", shape_intersects_shape(box0, box0), True)
transform = Transform(Vector(0.5, 1.5, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box1 = Box(transform)
test("Intersecting boxes", shape_intersects_shape(box0, box1), True)
transform = Transform(Vector(0.0, 3.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box2 = Box(transform)
test("Separate boxes", shape_intersects_shape(box0, box2), False)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 0.0), Vector(1.0, 1.0, 1.0))
box3 = Box(transform)
test("Intersecting boxes forming a star", shape_intersects_shape(box0, box3), True)
transform = Transform(Vector(2.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 1.0, 1.0))
box4 = Box(transform)
test("Boxes with faces touching", shape_intersects_shape(box0, box4), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box5 = Box(transform)
transform = Transform(Vector(0.0, 2.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(3.0, 1.0, 1.0))
box6 = Box(transform)
test("Tall and wide boxes forming a T", shape_intersects_shape(box5, box6), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 30.0), Vector(1.0, 4.0, 1.0))
box7 = Box(transform)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box8 = Box(transform)
test("Tall boxes forming an X", shape_intersects_shape(box7, box8), True)
transform = Transform(Vector(0.0, 0.0, 3.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0), Vector(1.0, 4.0, 1.0))
box9 = Box(transform)
test("Skew tall boxes", shape_intersects_shape(box7, box9), False)
transform = Transform(Vector(-5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(1.0, 4.0, 1.0))
box10 = Box(transform)
transform = Transform(Vector(5.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -45.0), Vector(1.0, 4.0, 1.0))
box11 = Box(transform)
test("Boxes forming a V with only edges touching", shape_intersects_shape(box10, box11), True)
transform = Transform(Vector(0.0, 0.0, 0.0), Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0), Vector(4.0, 4.0, 4.0))
box12 = Box(transform)
transform = Transform(Vector(0.5, 1.0, 0.0), Quaternion.from_axis_angle(Vector(1.5, 1.5, 1.0), 30.0), Vector(1.0, 1.0, 1.0))
box13 = Box(transform)
test("Container and contained boxes", shape_intersects_shape(box12, box13), True)
Centroids overlap
Same box: Passed
Successfully added vertex [x: -0.71, y: 4.24, z: 0.00]
Successfully added vertex [x: -0.71, y: -1.41, z: 0.00]
Successfully added vertex [x: 2.12, y: 1.41, z: 0.00]
Successfully added vertex [x: -0.71, y: 1.41, z: -2.00]
Origin in simplex
Intersecting boxes: Passed
Successfully added vertex [x: -3.54, y: 3.54, z: 0.00]
Failed to add vertex [x: -0.71, y: 0.71, z: 0.00]: Degenerate vertex already in simplex
Separate boxes: Passed
Centroids overlap
Intersecting boxes forming a star: Passed
Successfully added vertex [x: 2.83, y: 2.83, z: 0.00]
Degenerate case (solids touching)
Boxes with faces touching: Passed
Successfully added vertex [x: -6.36, y: 3.54, z: 0.00]
Successfully added vertex [x: 4.95, y: 0.71, z: 0.00]
Successfully added vertex [x: -0.71, y: -4.95, z: 0.00]
Successfully added vertex [x: -4.95, y: 2.12, z: 2.00]
Origin in simplex
Tall and wide boxes forming a T: Passed
Centroids overlap
Tall boxes forming an X: Passed
Successfully added vertex [x: -4.00, y: 1.00, z: 5.00]
Successfully added vertex [x: 5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: -5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: 0.00, y: 7.93, z: 1.00]
Origin  not in simplex
Successfully added vertex [x: 0.00, y: 7.93, z: 5.00]
Successfully added vertex [x: 0.00, y: -7.93, z: 1.00]
Successfully added vertex [x: 0.00, y: 7.93, z: 1.00]
Successfully added vertex [x: -5.73, y: 0.00, z: 3.00]
Origin  not in simplex
Successfully added vertex [x: -5.73, y: 0.00, z: 5.00]
Successfully added vertex [x: 5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: -5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: 0.00, y: -7.93, z: 3.00]
Origin  not in simplex
Successfully added vertex [x: 0.00, y: -7.93, z: 5.00]
Successfully added vertex [x: 0.00, y: 7.93, z: 1.00]
Successfully added vertex [x: 0.00, y: -7.93, z: 1.00]
Successfully added vertex [x: 5.73, y: 0.00, z: 3.00]
Origin  not in simplex
Successfully added vertex [x: 5.73, y: 0.00, z: 5.00]
Successfully added vertex [x: -5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: 5.73, y: 0.00, z: 1.00]
Successfully added vertex [x: 0.00, y: 7.93, z: 3.00]
Origin  not in simplex
Successfully added vertex [x: 0.00, y: 7.93, z: 5.00]
Successfully added vertex [x: 0.00, y: -7.93, z: 1.00]
Successfully added vertex [x: 0.00, y: 7.93, z: 1.00]
Failed to add vertex [x: -5.73, y: 0.00, z: 3.00]: Repeated simplex
Skew tall boxes: Passed
Successfully added vertex [x: 14.14, y: 0.00, z: 0.00]
Degenerate case (solids touching)
Boxes forming a V with only edges touching: Passed
Successfully added vertex [x: 0.71, y: 8.18, z: 3.40]
Successfully added vertex [x: -0.82, y: -5.51, z: -4.75]
Successfully added vertex [x: -0.10, y: -6.07, z: 5.03]
Successfully added vertex [x: 7.39, y: 0.12, z: -3.53]
Origin in simplex
Container and contained boxes: Passed

Hope this helps. Like I said I love this subject, and have learned a lot since my first reply to this thread.

2024-03-29 12:51:14

@16
Hello, sorry for the delay.
You provided me with a lot of study material, and I'm very grateful for that.
I haven't been able to look at it as I'd like, but I plan to do so in the next few weeks, when things calm down a bit around here.
I'll come back with updates on this same topic, and thank you very much!

2024-04-05 23:01:07

I wouldn't consider my job done here without extending the collision detection algorithm to also produce the minimum penetration vector required to push one of the objects away just enough so that they stop colliding, so I researched a little more and implemented the Expanding Polytope Algorithm, which integrates nicely with the Gilbert-Johnson-Keerthi algorithm by making use of the simplex that it generates as the initial polytope. I will also provide a description of both algorithms since although both are quite simple once we understand them, EPA took me quite some time to wrap my head around.

For both GJK and EPA we need a support function that computes the dot product between all the vertices in one shape and a given direction vector to return the vertex that is farther away in that direction, which is the vector with the highest dot product. This function is then used to compute points from the hull of the Minkowski difference cloud, which is the difference between all the combinations of vectors of both objects. One interesting fact about the Minkowski difference cloud is that its shape doesn't change when the distance between the two objects changes, and the minimum distance vector or minimum penetration vector is the vector from the projection of the origin on the nearest face of its hull to the origin. This fact can also be used to compute the separating plane between the two objects to prove the Separating Axis Theorem.

GJK requires trying to build a simplex (a tetrahedron in this case) around the origin using the exterior vertices of the Minkowski difference cloud. If you manage to draw such a simplex then the objects are colliding. It starts with a vector pointing in any direction at random, though some choices, like the difference between the centroids of both solids, should in theory result in less computations, so that's what I choose as the initial vector, unless the centroids are the same, in which case I just choose the unit vector on the X axis. Once we have a direction we use the support function to find the most extreme vector in one of the objects and then find the most extreme vector in the opposite direction on the other object. Subtracting one of the vectors from the other yields one of the vectors in the hull of the Minkowski cloud which is also the first point in our simplex. The second point is obtained from following the previous steps in the opposite direction of our first point towards the origin. The third point is obtained from computing a perpendicular vector of the line formed by the first two vectors that we found in the general direction of the origin., and finally the fourth point is obtained from using the support function as mentioned earlier with the direction perpendicular to the triangle formed by the first three vectors in the general direction of the origin. If in any of these steps one of the lines does not pass the origin then that is a termination condition since it is guaranteed that the objects aren't colliding. If any of the vertices is the origin then we have a degenerate case which means that the solids are barely touching. If the origin is part of any of the facets or edges of the simplex then the next direction is a random vector perpendicular to the edge or facet containing the origin. If the tetrahedron formed by these 4 points contains the origin then that's a termination condition for GJK as there's a collision, otherwise try forming a new tetrahedron starting from the last vertex, and repeat the whole process until you can either form a tetrahedron simplex with the origin inside, indicating a collision, or a previously attempted simplex is created again, in which case there's no collision. Do not terminate GJK earlier even if you find the origin on an edge or facet of the tetrahedron simplex without finishing finding the four points of the simplex, as the EPA in the next step starts from a fully built simplex.

EPA starts from a fully built simplex around the origin as mentioned in the previous paragraph, and here the goal is to generate an increasingly larger convex polytope (a polyhedron in this case) until the nearest facet of the Minkowski difference hull is found. To do so you look for the facet of the current polyhedron that is closest to the origin, use the support function to find the most extreme vertices of both objects just like in GJK but this time using the normal of the facet as direction, and add vertices to the polyhedron while making sure to keep it convex until you find a vertex that is already part of it. Once the algorithm terminates, the minimum penetration vector is the difference between the projection of the origin on the nearest facet of the generated polyhedron and the origin. This algorithm seems easier than GJK because it has way less rules but I took some time to figure out how to keep the polyhedron convex all the time with all the normals pointing outward.

Code and output follows:

import math

class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    def __repr__(self):
        return "[x: %.03f, y: %.03f, z: %.03f]" % (self.x, self.y, self.z)
    
    def __eq__(self, other):
        return round(self.x, 3) == round(other.x, 3) and round(self.y, 3) == round(other.y, 3) and round(self.z, 3) == round(other.z, 3)
    
    def __hash__(self):
        return hash((round(self.x, 3), round(self.y, 3), round(self.z, 3)))
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
    
    def __mul__(self, other):
        if isinstance(other, float):
            return Vector(self.x * other, self.y * other, self.z * other)
        return Vector(self.x * other.x, self.y * other.y, self.z * other.z)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
    
    def __truediv__(self, other):
        if isinstance(other, float):
            return Vector(self.x / other, self.y / other, self.z / other)
        return Vector(self.x / other.x, self.y / other.y, self.z / other.z)
    
    def __neg__(self):
        return Vector(-self.x, -self.y, -self.z)
    
    def dot(self, other):
        return round(self.x * other.x + self.y * other.y + self.z * other.z, 3)
    
    def cross(self, other):
        return Vector(self.y * other.z - self.z * other.y, self.z * other.x - self.x * other.z, self.x * other.y - self.y * other.x)
    
    def square_length(self):
        return self.dot(self)
    
    def length(self):
        return round(math.sqrt(self.square_length()), 3)
    
    def normalize(self):
        return self/ self.length()

Vector.ORIGIN = Vector(0.0, 0.0, 0.0)

class Quaternion:
    def __init__(self, vector, scalar):
        self.vector = vector
        self.scalar = scalar
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            conj = Quaternion(-self.vector, self.scalar)
            vec = Quaternion(other, 0.0)
            quat = self * vec * conj
            return quat.vector
        return Quaternion(self.vector.cross(other.vector) + self.vector * other.scalar + other.vector * self.scalar, self.scalar * other.scalar - self.vector.dot(other.vector))
    
    @staticmethod
    def from_axis_angle(axis, angle):
        sin = math.sin(angle / 360.0 * math.pi)
        vec = axis/ axis.length()
        return Quaternion(vec * sin, math.sqrt(1.0 - sin * sin))

class Transform:
    def __init__(self, vec0, vec1, vec2, vec3):
        self.vec0 = vec0
        self.vec1 = vec1
        self.vec2 = vec2
        self.vec3 = vec3
    
    def __add__(self, translation):
        return Transform(self.vec0, self.vec1, self.vec2, self.vec3 + translation)
    
    @staticmethod
    def from_components(translation, rotation, scale):
        return Transform(rotation * Vector(scale.x, 0.0, 0.0), rotation * Vector(0.0, scale.y, 0.0), rotation * Vector(0.0, 0.0, scale.z), rotation * translation)

class Simplex:
    def __init__(self):
        self.vertices = []
        self.attempts = []
        self.finished = False
    
    def add_vertex(self, vertex):
        if self.finished:
            return True
        # Reject if the last line segment formed with this vertex do not pass the origin.
        if self.vertices and vertex.dot(vertex - self.vertices[-1]) < 0.0:
            return False
        # Reject if the vertex is the origin.
        if vertex == Vector.ORIGIN:
            self.finished = True
            return False
        match len(self.vertices):
            case 2:
                # Reject if the line formed by the other two vertices contains this vertex.
                if vertex.cross(self.vertices[1] - self.vertices[0]) == Vector.ORIGIN:
                    return False
            case 3:
                # Reject if the vertex is coplanar the plane formed by the other three vertices.
                perp = (self.vertices[1] - self.vertices[0]).cross(self.vertices[2] - self.vertices[0])
                if perp.dot(vertex - self.vertices[0]) == 0.0:
                    return False
            case 4:
                self.vertices.clear()
        # Fail if the vertex is duplicate.
        if vertex in self.vertices:
            return False
        self.vertices.append(vertex)
        # Fail if this simplex has already been tried.
        if len(self.vertices) == 4:
            for attempt in self.attempts:
                for vertex in self.vertices:
                    if vertex not in attempt:
                        break
                else:
                    return False
            self.attempts.append(self.vertices.copy())
        return True
    
    def contains_origin(self):
        if self.finished:
            return len(self.vertices) == 4
        if len(self.vertices) == 4:
            # Fail if the origin is in front of any of the faces.
            for face in ((0, 1, 2, 3), (0, 1, 3, 2), (0, 2, 3, 1), (1, 2, 3, 0)):
                # Find the vector perpendicular to the face.
                perp = (self.vertices[face[1]] - self.vertices[face[0]]).cross(self.vertices[face[2]] - self.vertices[face[0]])
                # Make sure that the vector points outwards.
                perp = perp * perp.dot(self.vertices[face[0]] - self.vertices[face[3]])
                # Check whether the origin is in front of the face.
                if perp.dot(self.vertices[face[0]]) < 0.0:
                    break
            else:
                self.finished = True
        else:
            return False
        return self.finished
    
    def suggest_direction(self):
        if self.finished:
            return
        match len(self.vertices):
            case 1:
                # Return the direction to the origin.
                return -self.vertices[0]
            case 2:
                # If the segment crosses the origin, suggest a random perpendicular vector.
                if self.vertices[0].cross(self.vertices[1]) == Vector.ORIGIN:
                    perpx = Vector(1.0, 0.0, 0.0).cross(self.vertices[1])
                    return perpx if perpx != Vector.ORIGIN else Vector(0.0, 1.0, 0.0).cross(self.vertices[1])
                # Return a vector perpendicular to the line segment in the general direction of the origin.
                return (self.vertices[1] - self.vertices[0]).cross(-self.vertices[0]).cross(self.vertices[1] - self.vertices[0])
            case 3:
                # If the the origin is on the same plane as the triangle, return a random vector perpendicular to it.
                perp = (self.vertices[1] - self.vertices[0]).cross(self.vertices[2] - self.vertices[0])
                proj = perp.dot(self.vertices[0])
                if proj == 0.0:
                    return perp
                # Return the vector perpendicular to the triangle in the general direction of the origin.
                return (perp * -proj)

class Triangle:
    def __init__(self, vertex0, vertex1, vertex2):
        self.vertices = (vertex0, vertex1, vertex2)
        perp = (vertex1 - vertex0).cross(vertex2 - vertex0)
        self.normal = perp.normalize()
        self.distance = self.normal.dot(vertex0)
    
    def faces_point(self, point):
        return self.normal.dot(point - self.vertices[0]) > 0.0
    
    @staticmethod
    def from_simplex(simplex):
        tris = []
        for facet in ((0, 1, 2, 3), (1, 0, 3, 2), (2, 3, 0, 1), (3, 2, 1, 0)):
            perp = (simplex.vertices[facet[1]] - simplex.vertices[facet[0]]).cross(simplex.vertices[facet[2]] - simplex.vertices[facet[0]])
            if perp.dot(simplex.vertices[facet[0]] - simplex.vertices[facet[3]]) > 0.0:
                tris.append(Triangle(simplex.vertices[facet[0]], simplex.vertices[facet[1]], simplex.vertices[facet[2]]))
            else:
                tris.append(Triangle(simplex.vertices[facet[2]], simplex.vertices[facet[1]], simplex.vertices[facet[0]]))
        return tris

class Polyhedron:
    def __init__(self, simplex):
        # If one of the vertices of the simplex is the origin, then this is a degenerate case.
        self.finished = Vector.ORIGIN in simplex.vertices
        if self.finished:
            return
        self.triangles = Triangle.from_simplex(simplex)
        # Keep track of the number of references to each vertex of the polyhedron.
        self.vertex_count = {
            simplex.vertices[0]: 3,
            simplex.vertices[1]: 3,
            simplex.vertices[2]: 3,
            simplex.vertices[3]: 3
        }
    
    def add_triangle(self, triangle):
        self.triangles.append(triangle)
        for vert in triangle.vertices:
            ct = self.vertex_count.setdefault(vert, 0)
            self.vertex_count[vert] = ct + 1
    
    def remove_triangle(self, triangle):
        self.triangles.remove(triangle)
        for vert in triangle.vertices:
            ct = self.vertex_count[vert]
            self.vertex_count[vert] = ct - 1
    
    def add_vertex(self, vertex):
        if self.finished:
            return False
        # Terminate if this vertex has already been added.
        if vertex in self.vertex_count.keys():
            self.finished = True
            return False
        # Mark the triangles whose front faces can see the new vertex for deletion to prevent concavities.
        oldtris = []
        for tri in self.triangles:
            if tri.faces_point(vertex):
                oldtris.append(tri)
        # If the new vertex is part of the polyhedron then we have already found the face closest to the origin.
        if not oldtris:
            self.finished = True
            return False
        # Extract unique vectors from the triangles marked for deletion and delete them.
        rem = []
        for tri in oldtris:
            self.remove_triangle(tri)
            for vert in tri.vertices:
                if not vert in rem:
                    rem.append(vert)
        # Filter out the deleted vertices that don't have any other references.
        dangs = [x for x in filter(lambda x: self.vertex_count[x] > 0, rem)]
        # Compute an average vector between the new vertex and the dangling vertices.
        dirs = [x for x in map(lambda x: (x - vertex).normalize(), dangs)]
        avgdir = sum(dirs, Vector.ORIGIN)
        # Compute the horizontal, vertical, and depth axis.
        dp = -avgdir.normalize()
        vt = (avgdir).cross(dirs[0]).normalize()
        hz = avgdir.cross(vt).normalize()
        # Sort the dangling vertices by angle.
        dangdirs = [x for x in map(lambda x: (dangs[x], (dirs[x] - dp * dirs[x].dot(dp)).normalize()), range(len(dangs)))]
        dangdirs = [x for x in sorted(dangdirs, key = lambda x: -math.copysign(x[1].dot(hz) + 1.0, x[1].dot(vt)))]
        # Create the new triangles using the sorted angles to ensure that their normals point outwards.
        for idx in range(len(dangdirs)):
            tri = Triangle(dangdirs[idx][0], dangdirs[(idx + 1) % len(dangdirs)][0], vertex)
            self.add_triangle(tri)
        return True
    
    def suggest_direction(self):
        dir = None
        mindist = math.inf
        for tri in self.triangles:
            dist = tri.distance
            if dist < mindist:
                dir = tri.normal
                mindist = dist
        return dir
    
    def compute_origin_translation(self):
        if not self.finished:
            return
        mindist = math.inf
        norm = None
        for tri in self.triangles:
            dist = tri.distance
            if dist < mindist:
                mindist = dist
                norm = tri.normal
        if norm:
            pen = norm * -mindist
            if pen != Vector.ORIGIN:
                return pen

class Box:
    def __init__(self, transform):
        self.position = transform.vec3
        self.vertices = (
            self.position - transform.vec0 - transform.vec1 - transform.vec2,
            self.position + transform.vec0 - transform.vec1 - transform.vec2,
            self.position - transform.vec0 + transform.vec1 - transform.vec2,
            self.position + transform.vec0 + transform.vec1 - transform.vec2,
            self.position - transform.vec0 - transform.vec1 + transform.vec2,
            self.position + transform.vec0 - transform.vec1 + transform.vec2,
            self.position - transform.vec0 + transform.vec1 + transform.vec2,
            self.position + transform.vec0 + transform.vec1 + transform.vec2
        )
    
    def find_support_point(self, direction):
        if direction == Vector.ORIGIN:
            raise Exception("Direction is null vector")
        # Find the farthest point in a specific direction.
        best = None
        max = -math.inf
        for vertex in self.vertices:
            dot = vertex.dot(direction)
            if dot > max:
                best = vertex
                max = dot
        return best

def shape_intersects_shape(shape0, shape1):
    smpx = Simplex()
    # Generate the first direction vector.
    dir = shape1.position - shape0.position
    if dir == Vector.ORIGIN:
        dir = Vector(0.0, 0.0, 1.0)
    # Check whether the shapes are intersecting using GJK.
    while True:
        point0 = shape0.find_support_point(-dir)
        point1 = shape1.find_support_point(dir)
        diff = point1 - point0
        # If a vertex fails to be added, there's no way to progress further.
        if not smpx.add_vertex(diff):
            return
        # If the simplex contains the origin, then the solids are overlapping.
        if smpx.contains_origin():
            break
        dir = smpx.suggest_direction()
        # If no direction is suggested, indicating no simplex vertices, aim at the previously defined difference.
        if not dir:
            dir = diff
    # Compute the minimum penetration vector using EPA.
    pldr = Polyhedron(smpx)
    while True:
        dir = pldr.suggest_direction()
        point0 = shape0.find_support_point(-dir)
        point1 = shape1.find_support_point(dir)
        diff = point1 - point0
        if not pldr.add_vertex(diff):
            return pldr.compute_origin_translation()

def test(name, shape0, shape1):
    push = shape_intersects_shape(shape0, shape1)
    res = None
    if push:
        shape2 = Box(transform + push)
        res = shape_intersects_shape(shape0, shape2) if push else None
    print("%s: %s (pushed: %s)" % (name, "Passed" if not res else "Failed", push))

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box0 = Box(transform)
test("Same box", box0, box0)

translation = Vector(0.5, 1.5, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box1 = Box(transform)
test("Intersecting boxes", box0, box1)

translation = Vector(0.0, 3.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box2 = Box(transform)
test("Separate boxes", box0, box2)

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(1.0, 0.0, 0.0), 45) * Quaternion.from_axis_angle(Vector(1.0, 0.0, 0.0), 45)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box3 = Box(transform)
test("Intersecting boxes forming a star", box0, box3)

translation = Vector(0.0, 2.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box4 = Box(transform)
test("Boxes with faces touching", box0, box4)

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box5 = Box(transform)
translation = Vector(0.0, 2.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(3.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box6 = Box(transform)
test("Tall and wide boxes forming a cross", box5, box6)

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 30.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box7 = Box(transform)
translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box8 = Box(transform)
test("Tall boxes forming an X", box7, box8)

translation = Vector(0.0, 0.0, 3.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -30.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box9 = Box(transform)
test("Skew tall boxes", box7, box9)

translation = Vector(-5.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box10 = Box(transform)
translation = Vector(5.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), -45.0)
scale = Vector(1.0, 4.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box11 = Box(transform)
test("Boxes forming a V with only edges touching", box10, box11)

translation = Vector(0.0, 0.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(4.0, 4.0, 4.0)
transform = Transform.from_components(translation, rotation, scale)
box12 = Box(transform)
translation = Vector(0.5, 1.0, 0.0)
rotation = Quaternion.from_axis_angle(Vector(0.0, 0.0, 1.0), 45.0)
scale = Vector(1.0, 1.0, 1.0)
transform = Transform.from_components(translation, rotation, scale)
box13 = Box(transform)
test("Container and contained boxes", box12, box13)
Same box: Passed (pushed: [x: -1.414, y: -1.414, z: -0.000])
Intersecting boxes: Passed (pushed: [x: -0.354, y: 0.354, z: -0.000])
Separate boxes: Passed (pushed: None)
Intersecting boxes forming a star: Passed (pushed: [x: -0.000, y: -0.001, z: -2.001])
Boxes with faces touching: Passed (pushed: None)
Tall and wide boxes forming a cross: Passed (pushed: [x: -0.000, y: -0.000, z: 2.000])
Tall boxes forming an X: Passed (pushed: [x: 0.000, y: -0.000, z: 2.000])
Skew tall boxes: Passed (pushed: None)
Boxes forming a V with only edges touching: Passed (pushed: None)
Container and contained boxes: Passed (pushed: [x: -2.828, y: 2.828, z: -0.000])

PS: VoiceOver on MacOS isn't reading the entire GJK paragraph for some reason, so if you are on a Mac I recommend using caret browsing to read it line by line.