geom
import geom from "geom";
import math3d from "math3d";
let wall = geom.aabb2(math3d.vec2(4.0, 0.0), math3d.vec2(5.0, 3.0));
let ray = geom.ray2(math3d.vec2(0.0, 1.0), math3d.vec2(1.0, 0.0));
let hit = ray.cast_aabb(wall); // ?Hit2: t 4, point (4, 1), normal (-1, 0)
let at: f32 = hit!.t; // 4geom answers the questions a game asks of its shapes: do these touch,
where does this ray hit, how far do I push this out, what is near this
box. It works on math3d’s f32 vectors Vec2 and Vec3.
It is pure (t1): only the polygon functions and the spatial structures
allocate.
Coordinates are right-handed with y up, angles in radians. A polygon is a
[]const Vec2; counter-clockwise means a positive signed area with y
up (on a y-down screen the same polygon looks clockwise).
Touching counts. Every overlap and containment test is inclusive: boxes sharing an edge overlap, a point on a circle is inside it. The one exception is the swept box (below).
Shapes
| Type | Fields | Made by |
|---|---|---|
Aabb2, Aabb3 | min, max (min <= max) | aabb2(min, max), aabb2_around(center, half), aabb2_of_points(pts) -> ?Aabb2 (and aabb3_...) |
Circle, Sphere | center, radius | circle(c, r), sphere(c, r) |
Obb2 | center, half, axis (its local x axis, unit length) | obb2(center, half, angle) |
Ray2, Ray3 | origin, dir (any length) | ray2(o, d), ray3(o, d) |
Segment2, Segment3 | a, b | segment2(a, b), segment3(a, b) |
Plane | normal (unit), d: the points with normal.dot(p) == d | plane(normal, point), plane_from_points(a, b, c) -> ?Plane |
Triangle | a, b, c; the front faces (b - a).cross(c - a) | triangle(a, b, c) |
let box = geom.aabb2_around(math3d.vec2(0.0, 0.0), math3d.vec2(1.0, 1.0)); // -1..1
let tilted = geom.obb2(math3d.vec2(3.0, 0.0), math3d.vec2(1.0, 0.5), 0.5);
let floor = geom.plane(math3d.vec3(0.0, 1.0, 0.0), math3d.vec3(0.0, 0.0, 0.0));plane makes the normal unit length (a zero normal traps);
plane_from_points gives null for three points on a line.
Results
A test that can miss returns an optional:
| Type | Fields | Meaning |
|---|---|---|
Hit2, Hit3 | t, point, normal | Where a ray or segment met a shape: t along it, the point, the shape’s unit normal there facing back along the ray |
Mtv | normal, depth | Moving A by normal * depth separates A from B (they are left touching) |
A ray’s t is in units of dir: with a unit dir it is the distance.
A ray that starts inside a shape hits it at t = 0, at its origin, with
a zero normal.
Boxes
| Method | Gives |
|---|---|
b.center(), b.size(), b.half() | The centre, the size, half the size |
b.merge(o) | The smallest box holding both |
b.expand(margin) | Grown by margin on every side |
b.contains(p), b.contains_aabb(o) | Is p (or all of o) inside? |
b.overlaps(o) | Do they share a point? |
b.closest_point(p) | The point of the box nearest p |
b == o | The same corners |
b.mtv(o) -> ?Mtv | How to push b out of o (2D only) |
b.sweep(vel, o) -> ?Hit2 | b moving by vel against the still o (2D only) |
let player = geom.aabb2(math3d.vec2(0.0, 0.0), math3d.vec2(2.0, 2.0));
let crate = geom.aabb2(math3d.vec2(1.5, 0.5), math3d.vec2(4.0, 1.5));
let m = player.mtv(crate)!; // normal (-1, 0), depth 0.5: push left by 0.5mtv pushes along the axis of least overlap (on a tie, x).
Swept boxes
sweep gives the time of impact (t in 0..1 of vel), the normal of
the face it meets, and the moving box’s centre at that moment:
let feet = geom.aabb2(math3d.vec2(0.0, 0.0), math3d.vec2(1.0, 1.0));
let wall = geom.aabb2(math3d.vec2(3.0, 0.0), math3d.vec2(4.0, 1.0));
let h = feet.sweep(math3d.vec2(4.0, 0.0), wall)!; // t 0.5, normal (-1, 0)
let pos = h.point; // the centre at impact: (2.5, 0.5)It reports when the boxes would start to overlap with area. Boxes that
only touch, or slide along each other, never hit, so a box standing on a
floor can walk along it; pushing into the floor hits at t = 0 with the
floor’s normal. Boxes already overlapping hit at t = 0 with a zero
normal: separate them with mtv. Hitting a corner exactly takes the x
face; only grazing a corner is no hit.
Circles and spheres
| Method | Gives |
|---|---|
c.contains(p) | Is p inside or on it? |
c.overlaps(o), c.overlaps_aabb(b) | Does it touch the other circle, the box? |
c.bounds() | Its box |
c.mtv(o) -> ?Mtv, c.mtv_aabb(b) -> ?Mtv | How to push it out of the other circle, the box (2D only) |
A circle whose centre is inside a box is pushed out through the nearest face. Spheres have the same methods without the MTVs.
Turned boxes and convex polygons
| Function | Gives |
|---|---|
o.corners() -> [4]Vec2 | Counter-clockwise |
o.contains(p), o.bounds() | Containment; the box around it |
o.overlaps(other), o.mtv(other) -> ?Mtv | Two turned boxes |
convex_overlaps(a, b), convex_mtv(a, b) -> ?Mtv | Two convex polygons, either winding |
These use separating axes (each polygon’s edge normals). A polygon must be convex and have at least 3 points; fewer never overlap anything, and a concave one gives a wrong answer.
let tri = [math3d.vec2(0.0, 0.0), math3d.vec2(2.0, 0.0), math3d.vec2(0.0, 2.0)];
let sq = [math3d.vec2(0.75, 0.75), math3d.vec2(2.0, 0.75), math3d.vec2(2.0, 2.0), math3d.vec2(0.75, 2.0)];
let m = geom.convex_mtv(tri[0..3], sq[0..4])!; // normal (-0.707, -0.707), depth 0.354Rays and segments
| Method | Hits |
|---|---|
r.cast_aabb(b), r.cast_circle(c), r.cast_segment(s), r.cast_obb(o) | 2D: ?Hit2 |
r.cast_aabb(b), r.cast_sphere(s), r.cast_plane(p), r.cast_triangle(t) | 3D: ?Hit3 |
s.intersect(o) -> ?Hit2 | Where segment s crosses o: t along s (0..1), o’s normal facing s.a |
r.at(t) | origin + dir * t |
let ground = geom.triangle(math3d.vec3(0.0, 0.0, 0.0), math3d.vec3(1.0, 0.0, 0.0), math3d.vec3(0.0, 1.0, 0.0));
let down = geom.ray3(math3d.vec3(0.25, 0.25, 1.0), math3d.vec3(0.0, 0.0, -1.0));
let h = down.cast_triangle(ground)!; // t 1, point (0.25, 0.25, 0), normal (0, 0, 1)- Planes and triangles are hit from either side; the normal faces the ray. Triangle edges and corners count (Möller–Trumbore).
- A ray hitting a box’s corner takes the earlier axis’s face (x, y, z).
- Parallel in 2D means exactly parallel: a ray along a segment, or two
segments on one line, miss even when they overlap. In 3D a ray within
EPSILON(1e-6, the sine of the angle) of a plane’s or triangle’s direction misses; the test doesn’t depend on scale. - A zero-length segment is never crossed; a ray with a zero direction hits only a shape it starts in.
Closest points
| Method | Gives |
|---|---|
s.closest_point(p) | On a segment (2D or 3D) |
b.closest_point(p) | On a box (2D or 3D) |
t.closest_point(p) | On a triangle; a degenerate one: the nearest point of its edges |
pl.closest_point(p), pl.distance(p) | On a plane; how far in front of it (negative behind) |
t.normal(), t.bounds() | A triangle’s unit normal (zero when degenerate), its box |
s.length() | A segment’s length |
Polygons
| Function | Gives |
|---|---|
polygon_area(pts) -> f32 | The signed area: positive counter-clockwise |
polygon_winding(pts) -> Winding | CounterClockwise, Clockwise or Degenerate (area 0) |
polygon_centroid(pts) -> ?Vec2 | The centre of its area; null when the area is 0 |
polygon_contains(pts, p) | Is p inside (a point on an edge is)? Any simple polygon, either winding |
convex_hull(pts) -> collections.Vec_i64 | The hull’s corners as indices into pts, counter-clockwise |
triangulate(pts) -> collections.Vec_i64 | Triangles as indices, three per triangle |
// an L: 2 x 1 below, 1 x 1 above on the left
let l = [math3d.vec2(0.0, 0.0), math3d.vec2(2.0, 0.0), math3d.vec2(2.0, 1.0),
math3d.vec2(1.0, 1.0), math3d.vec2(1.0, 2.0), math3d.vec2(0.0, 2.0)];
let area = geom.polygon_area(l[0..6]); // 3
let inside = geom.polygon_contains(l[0..6], math3d.vec2(1.5, 1.5)); // false: the notch
let tris = geom.triangulate(l[0..6]); // 0 1 2, 0 2 3, 0 3 4, 0 4 5convex_hullstarts at the leftmost point (the lowest of those), leaves out points on an edge, and keeps the lowest index of repeated points. A single point gives one index, points on one line the two ends.triangulateclips ears from any simple polygon, concave included, either winding. Every triangle comes out counter-clockwise, and none has zero area: a point on the line through its neighbours, or a repeated point, may be dropped, so you get at mostn - 2triangles. A polygon that crosses itself still gets triangles, but they may overlap.
Spatial structures
Two generators make a broadphase for your item type (an entity id, an
index, a struct). The i64 ones are ready: geom.GridHash_i64 and
geom.Quadtree_i64. For another type, call the generator once:
@tape { geom.gen_grid_hash(Entity); } // GridHash_Entity, grid_hash_Entity_newBoth store copies of the items (strings and other managed values too), and a query is a loop over the items whose boxes overlap the query box:
for (id in grid.query(box)) { ... }Queries don’t change the structure, so loops can nest; insert, remove or clear only after the loop.
Grid hash
For many moving objects: every frame, clear it, insert every object, then query.
var grid = geom.grid_hash_i64_new(32.0); // cells 32 x 32
// each frame:
grid.clear();
for (e in entities) { grid.insert(e.id, e.box); }
for (other in grid.query(player.box.expand(4.0))) { ... }| Function | |
|---|---|
grid_hash_$T_new(cell: f32) | An empty grid of square cells; cell must be positive |
g.insert(item, box) | Adds an item with its box |
g.query(box) | The items whose boxes overlap box, each once |
g.pairs() | Every pair of items whose boxes overlap, each once, as a geom.GridPair { a, b } of their indices (a < b) |
g.get(i), g.box_of(i) | Item i (in insertion order since the last clear) and its box |
g.clear() | Removes everything, keeps the memory |
g.len(), g.cell_size() |
For a broadphase, pairs() hands every overlapping pair to the narrow
phase once, however many cells the two share; touching boxes count, as
in overlaps. It is a loop like query, allocates nothing, and the
indices are only good until the next insert or clear:
for (p in grid.pairs()) {
collide(grid.get(p.a), grid.get(p.b));
}Pick a cell about the size of a typical object. The grid has no bounds:
cells are hashed. A box covering more than GRID_MAX_CELLS (64) cells,
such as a level-wide trigger, goes into one list every query reads.
Quadtree
For objects that mostly stay put or move a little: insert once, move when they move.
var tree = geom.quadtree_i64_new(geom.aabb2(math3d.vec2(0.0, 0.0), math3d.vec2(4096.0, 4096.0)), 8, 4);
let h = tree.insert(42, coin_box);
tree.move(h, coin_box_next);
for (id in tree.query(view)) { draw(id); }
let gone = tree.remove(h); // 42| Function | |
|---|---|
quadtree_$T_new(bounds, max_depth, capacity) | An empty tree; a node with more than capacity items splits, down to max_depth (at most QUAD_MAX_DEPTH, 20) |
t.insert(item, box) -> i64 | Adds an item; its handle |
t.remove(h) -> T | Takes the item out |
t.move(h, box) | A new box for the item |
t.get(h), t.box_of(h) | The item, its box |
t.query(box) | The items whose boxes overlap box |
t.clear(), t.len() |
A handle stays valid until its item is removed (then it may be reused);
using a handle that isn’t live traps. Items outside bounds still work:
they live at the top and every query looks at them. A subtree that falls
to capacity items merges back, so a tree that empties shrinks.
Last modified: