diff --git a/project.godot b/project.godot
index 774a21b..5dc70e2 100644
--- a/project.godot
+++ b/project.godot
@@ -19,6 +19,12 @@ config/icon="res://icon.svg"
ImGuiRoot="*res://addons/imgui-godot/data/ImGuiRoot.tscn"
+[display]
+
+window/size/viewport_width=1920
+window/size/viewport_height=1080
+window/stretch/mode="viewport"
+
[dotnet]
project/assembly_name="Quadratic.Carto"
diff --git a/res/scene/test.tscn b/res/scene/test.tscn
index b46541d..d8902af 100644
--- a/res/scene/test.tscn
+++ b/res/scene/test.tscn
@@ -1,6 +1,60 @@
-[gd_scene load_steps=2 format=3 uid="uid://tht1tf5iq6lw"]
+[gd_scene load_steps=11 format=3 uid="uid://tht1tf5iq6lw"]
-[ext_resource type="Script" path="res://src/Hello.cs" id="1_ex5yf"]
+[ext_resource type="Script" path="res://src/testing/TestThruster.cs" id="1_y7wni"]
-[node name="Node3D" type="Node3D"]
-script = ExtResource("1_ex5yf")
+[sub_resource type="BoxShape3D" id="BoxShape3D_xxi7g"]
+size = Vector3(16.9697, 0.0310059, 15.6934)
+
+[sub_resource type="PlaneMesh" id="PlaneMesh_xcndr"]
+size = Vector2(20, 20)
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_8al46"]
+
+[sub_resource type="BoxMesh" id="BoxMesh_jgj3c"]
+
+[sub_resource type="PhysicalSkyMaterial" id="PhysicalSkyMaterial_uxbcq"]
+
+[sub_resource type="Sky" id="Sky_iakm3"]
+sky_material = SubResource("PhysicalSkyMaterial_uxbcq")
+
+[sub_resource type="Environment" id="Environment_0rcmt"]
+background_mode = 2
+sky = SubResource("Sky_iakm3")
+
+[sub_resource type="CameraAttributesPhysical" id="CameraAttributesPhysical_irbnp"]
+
+[sub_resource type="Compositor" id="Compositor_tt8nt"]
+
+[node name="root" type="Node3D"]
+
+[node name="ground" type="StaticBody3D" parent="."]
+disable_mode = 1
+input_ray_pickable = false
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="ground"]
+shape = SubResource("BoxShape3D_xxi7g")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="ground"]
+mesh = SubResource("PlaneMesh_xcndr")
+
+[node name="test-rocket" type="RigidBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.815224, 0)
+script = ExtResource("1_y7wni")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="test-rocket"]
+shape = SubResource("BoxShape3D_8al46")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="test-rocket"]
+mesh = SubResource("BoxMesh_jgj3c")
+
+[node name="Camera3D" type="Camera3D" parent="test-rocket"]
+transform = Transform3D(1, 0, 0, 0, 0.91772, 0.397228, 0, -0.397228, 0.91772, 0, 4.51745, 14.147)
+current = true
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(0.767277, -0.356166, -0.533322, 0.641316, 0.42612, 0.638072, 0, -0.831605, 0.555367, -9.52374, 5.38917, 8.97674)
+
+[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+environment = SubResource("Environment_0rcmt")
+camera_attributes = SubResource("CameraAttributesPhysical_irbnp")
+compositor = SubResource("Compositor_tt8nt")
diff --git a/src/math/Ray.cs b/src/math/Ray.cs
new file mode 100644
index 0000000..5557a5f
--- /dev/null
+++ b/src/math/Ray.cs
@@ -0,0 +1,128 @@
+using Godot;
+
+namespace Quadratic.Carto.MathExt;
+
+public struct Ray
+{
+ public Vector3 origin;
+ public Vector3 direction;
+
+ ///
+ /// Creates a new ray, using origin and normalized direction.
+ ///
+ /// The origin vector.
+ /// The direction vector, normalized.
+ public Ray(Vector3 origin, Vector3 direction)
+ {
+ this.origin = origin;
+ this.direction = direction;
+ }
+
+ ///
+ /// Creates a new ray, using start and end points.
+ ///
+ /// The start point vector.
+ /// The end point vector.
+ public static Ray FromStartAndEnd(Vector3 start, Vector3 end)
+ {
+ return new Ray(start, (end - start).Normalized());
+ }
+
+ ///
+ /// Creates a new ray, using origin and non-normalized direction.
+ ///
+ /// The origin vector.
+ /// The direction vector, non-normalized.
+ public static Ray FromOriginAndDirection(Vector3 origin, Vector3 direction)
+ {
+ return new Ray(origin, direction.Normalized());
+ }
+
+ ///
+ /// Creates a new ray, using origin and normalized direction.
+ ///
+ /// The origin vector.
+ /// The direction vector, normalized.
+ public static Ray FromOriginAndNormalizedDirection(Vector3 origin, Vector3 direction)
+ {
+ return new Ray(origin, direction);
+ }
+
+ ///
+ /// Creates a new ray, using origin and rotation quaternion.
+ ///
+ /// The origin vector.
+ /// The rotation quaternion.
+ public static Ray FromOriginAndRotation(Vector3 origin, Quaternion rotation)
+ {
+ return new Ray(origin, rotation * Vector3.Forward);
+ }
+
+ ///
+ /// Get the point at the given distance along the ray.
+ ///
+ /// The distance along the ray.
+ public readonly Vector3 GetPoint(float distance)
+ {
+ return this.origin + this.direction * distance;
+ }
+
+ ///
+ /// Returns the point along the ray that is closest to the given point.
+ ///
+ ///
+ ///
+ public readonly float MinDistancePoint(Vector3 point)
+ {
+ /*
+ The minimum distance between a ray and a point:
+
+ d: direction
+ ------+--------------<----* O: origin
+ | \
+ * P * P'
+
+ If the line between origin and target point (OP) is on the same
+ side as d, the distance is the magnitude of the vertical line
+ between P and Od. Else (OP'), the distance is simply the distance
+ between O and P.
+ */
+
+ var op = point - this.origin; // vector OP
+ var opd = op.Dot(this.direction); // OP dot d is the projection of OP onto d
+ if (opd < 0) // OP' case
+ {
+ return op.Length();
+ }
+ else // OP case
+ {
+ var od = this.direction * opd; // vector Od
+ return (op - od).Length();
+ }
+ }
+
+ public readonly Vector3 ClosestPoint(Vector3 point)
+ {
+ var pointDist = MinDistancePoint(point);
+ return GetPoint(pointDist);
+ }
+
+ public readonly Ray Transform(Transform3D transform)
+ {
+ return new Ray(
+ transform.Origin + transform.Basis * this.origin,
+ (transform.Basis * this.direction).Normalized()
+ );
+ }
+
+ public readonly Ray InverseTransform(Transform3D transform)
+ {
+ var inv = transform.Inverse();
+ return Transform(inv);
+ }
+
+ public override readonly string ToString()
+ {
+ return $"Ray({this.origin}, {this.direction})";
+ }
+}
diff --git a/src/math/VectorsExt.Conversion.cs b/src/math/VectorsExt.Conversion.cs
new file mode 100644
index 0000000..2a2b159
--- /dev/null
+++ b/src/math/VectorsExt.Conversion.cs
@@ -0,0 +1,48 @@
+namespace Quadratic.Carto.MathExt;
+
+
+public static partial class VectorsExt
+{
+ public static Godot.Vector3 AsGodot(this System.Numerics.Vector3 v)
+ {
+ return new Godot.Vector3(v.X, v.Y, v.Z);
+ }
+
+ public static System.Numerics.Vector3 AsSystem(this Godot.Vector3 v)
+ {
+ return new System.Numerics.Vector3(v.X, v.Y, v.Z);
+ }
+
+ public static Godot.Vector2 AsGodot(this System.Numerics.Vector2 v)
+ {
+ return new Godot.Vector2(v.X, v.Y);
+ }
+
+ public static System.Numerics.Vector2 AsSystem(this Godot.Vector2 v)
+ {
+ return new System.Numerics.Vector2(v.X, v.Y);
+ }
+
+ public static Godot.Quaternion AsGodot(this System.Numerics.Quaternion q)
+ {
+ return new Godot.Quaternion(q.X, q.Y, q.Z, q.W);
+ }
+
+ public static System.Numerics.Quaternion AsSystem(this Godot.Quaternion q)
+ {
+ return new System.Numerics.Quaternion(q.X, q.Y, q.Z, q.W);
+ }
+
+ public static Godot.Vector4 AsGodot(this System.Numerics.Vector4 v)
+ {
+ return new Godot.Vector4(v.X, v.Y, v.Z, v.W);
+ }
+
+ public static System.Numerics.Vector4 AsSystem(this Godot.Vector4 v)
+ {
+ return new System.Numerics.Vector4(v.X, v.Y, v.Z, v.W);
+ }
+}
+
+
+
diff --git a/src/math/VectorsExt.Math.cs b/src/math/VectorsExt.Math.cs
new file mode 100644
index 0000000..4c3ae7e
--- /dev/null
+++ b/src/math/VectorsExt.Math.cs
@@ -0,0 +1,99 @@
+using Godot;
+
+namespace Quadratic.Carto.MathExt;
+
+public static partial class VectorsExt
+{
+ public static Quaternion FromToRotation(Vector3 from, Vector3 to)
+ {
+ var fromDotTo = from.Dot(to);
+ if (Mathf.IsEqualApprox(fromDotTo, 1))
+ {
+ return Quaternion.Identity;
+ }
+ else if (Mathf.IsEqualApprox(fromDotTo, -1))
+ {
+ return new Quaternion(Vector3.Right, Mathf.Pi);
+ }
+ var axis = from.Cross(to).Normalized();
+ var angle = Mathf.Acos(fromDotTo);
+ return new Quaternion(axis, angle);
+ }
+
+ ///
+ /// Calculates the closest distance between two lines, defined by origin and direction. This
+ /// method assumes normalized direction vectors.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static float ClosestDistanceBetweenLines(
+ Vector3 aOrigin,
+ Vector3 aDirection,
+ Vector3 bOrigin,
+ Vector3 bDirection)
+ {
+ // Rule out the case where the lines are parallel.
+ float aDotB = aDirection.Dot(bDirection);
+ if (Mathf.IsEqualApprox(aDotB, 1))
+ {
+ // Lines are parallel
+ var aToB = bOrigin - aOrigin;
+ var aPerp = aToB - aDirection * aToB.Dot(aDirection);
+ return aPerp.Length();
+ }
+ else
+ {
+ // The vector that's perpendicular to both lines.
+ // Since both lines are normalized, this vector is also normalized.
+ var normalAB = aDirection.Cross(bDirection);
+ var aToB = bOrigin - aOrigin;
+ return aToB.Dot(normalAB);
+ }
+ }
+
+ ///
+ /// Calculates the closest distance and points between two lines, defined by origin and
+ /// direction. This method assumes normalized direction vectors.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static float ClosestDistanceBetweenLines(
+ Vector3 aOrigin,
+ Vector3 aDirection,
+ Vector3 bOrigin,
+ Vector3 bDirection,
+ out Vector3 aClosest,
+ out Vector3 bClosest)
+ {
+ // Rule out the case where the lines are parallel.
+ float aDotB = aDirection.Dot(bDirection);
+ if (Mathf.IsEqualApprox(aDotB, 1))
+ {
+ // Lines are parallel
+ var aToB = bOrigin - aOrigin;
+ var aPerp = aToB - aDirection * aToB.Dot(aDirection);
+ aClosest = aOrigin;
+ bClosest = bOrigin + aPerp;
+ return aPerp.Length();
+ }
+ else
+ {
+ // https://en.wikipedia.org/wiki/Skew_lines#Nearest_Points
+ // The vector that's perpendicular to both lines.
+ // Since both lines are normalized, this vector is also normalized.
+ var n = aDirection.Cross(bDirection);
+ var n1 = aDirection.Cross(n);
+ var n2 = bDirection.Cross(n);
+ var aToB = bOrigin - aOrigin;
+ aClosest = aOrigin + aToB.Dot(n2) / aDirection.Dot(n2) * aDirection;
+ bClosest = bOrigin + aToB.Dot(n1) / bDirection.Dot(n1) * bDirection;
+ return aToB.Dot(n);
+ }
+ }
+}
diff --git a/src/testing/TestThruster.cs b/src/testing/TestThruster.cs
new file mode 100644
index 0000000..4c0b5cb
--- /dev/null
+++ b/src/testing/TestThruster.cs
@@ -0,0 +1,113 @@
+using Godot;
+using ImGuiNET;
+using Quadratic.Carto.MathExt;
+
+namespace Quadratic.Carto.Testing;
+
+public partial class TestThruster : RigidBody3D
+{
+ ///
+ /// Throttle level between 0 and 1
+ ///
+ public float Throttle
+ {
+ get => _throttle; set
+ {
+ _throttle = Mathf.Clamp(value, 0.0f, 1.0f);
+ }
+ }
+ float _throttle = 0.0f;
+
+ Vector3 torque = new Vector3();
+
+ bool stabilize = false;
+ bool stabilizeDebounce = false;
+
+ public override void _Process(double delta)
+ {
+ if (Input.IsKeyPressed(Key.Shift))
+ {
+ Throttle += 0.1f * (float)delta;
+ }
+ else if (Input.IsKeyPressed(Key.Ctrl))
+ {
+ Throttle -= 0.1f * (float)delta;
+ }
+
+ torque = Vector3.Zero;
+ if (Input.IsKeyPressed(Key.W))
+ {
+ torque += new Vector3(-1.0f, 0.0f, 0.0f); // Forward (X-)
+ }
+ if (Input.IsKeyPressed(Key.S))
+ {
+ torque += new Vector3(1.0f, 0.0f, 0.0f); // Backward (X+)
+ }
+ if (Input.IsKeyPressed(Key.A))
+ {
+ torque += new Vector3(0.0f, 0.0f, 1.0f); // Left (Z+)
+ }
+ if (Input.IsKeyPressed(Key.D))
+ {
+ torque += new Vector3(0.0f, 0.0f, -1.0f); // Right (Z-)
+ }
+ if (Input.IsKeyPressed(Key.Q))
+ {
+ torque += new Vector3(0.0f, 1.0f, 0.0f); // Up (Y+)
+ }
+ if (Input.IsKeyPressed(Key.E))
+ {
+ torque += new Vector3(0.0f, -1.0f, 0.0f); // Down (Y-)
+ }
+ torque *= 0.01f;
+
+ if (Input.IsKeyPressed(Key.T) && !stabilizeDebounce)
+ {
+ stabilizeDebounce = true;
+ stabilize = !stabilize;
+ }
+ else if (!Input.IsKeyPressed(Key.T) && stabilizeDebounce)
+ {
+ stabilizeDebounce = false;
+ }
+
+ // Debug window
+ ImGui.Begin("Debug");
+ ImGui.Text("Status");
+ ImGui.LabelText("Position", this.GlobalPosition.ToString());
+ var vel = this.LinearVelocity.AsSystem();
+ ImGui.SliderFloat3("Velocity", ref vel, -100.0f, 100.0f);
+ var angVel = this.AngularVelocity.AsSystem();
+ ImGui.SliderFloat3("Angular Velocity", ref angVel, -100.0f, 100.0f);
+
+ ImGui.Text("Controls");
+ ImGui.SliderFloat("Throttle", ref _throttle, 0.0f, 1.0f);
+ ImGui.Checkbox("Stabilize", ref stabilize);
+ var torqueVec = torque.AsSystem();
+ ImGui.SliderFloat3("Torque", ref torqueVec, -1.0f, 1.0f);
+ ImGui.End();
+ }
+
+ public override void _PhysicsProcess(double delta)
+ {
+ var thrust = Throttle * 100.0f;
+ var thrustVec = this.Transform * new Vector3(0.0f, thrust, 0f);
+ this.ApplyCentralForce(thrustVec);
+
+ Vector3 torque;
+ if (!this.torque.IsZeroApprox())
+ {
+ torque = this.torque;
+ }
+ else if (stabilize)
+ {
+ torque = -AngularVelocity * 0.1f;
+ }
+ else
+ {
+ torque = Vector3.Zero;
+ }
+ ApplyTorque(torque);
+ }
+
+}