@expofp/geometry
Zero-dependency 2.5D geometry primitives for the ExpoFP SDK: Point, Line, Box, Rect,
Polygon, Mesh, plus the free functions that operate on them. No runtime dependencies (not even
three).
"2.5D" means: a 3D Point vector, but the shapes are 2D geometry positioned in 3D by an
elevation — a flat shape at a given height. All spatial math (distance, angle, area, containment,
intersection) is computed in the xy plane; elevation positions the shape and gates intersections
(see Coordinate convention). The one genuinely-3D shape is Mesh (a triangle mesh whose vertices carry
real z), for renderer geometry that isn't flat.
Primitives
Point | A mutable 3D vector (x, y, z). width/height alias x/y so a point can double as a size. The low-level building block the shapes write into. |
Line | A segment between two 2D points (p0, p1) at a single elevation (default 0). |
Box | An axis-aligned rectangle (min/max corners, cached center/size). 2D, infinite in z (no elevation) — for bounding boxes and screen sizes. A Box structurally satisfies RectLike, so anything taking a RectLike accepts a Box (an unrotated, elevation-less rect that intersects at any height). |
Rect | A rectangle that may be rotated: center + size + rotation + elevation (both default 0). For oriented rectangular shapes at a given height. |
Polygon | A planar triangulated polygon: 2D vertices + triangle indices + an elevation, with a cached bounding Box. Has containsPoint/area (well-defined because it's flat). Build a convex one from an ordered ring with Polygon.fromConvexRing. |
Mesh | A 3D triangle mesh: 3D vertices + indices, with a cached (xy) bounding Box. Transforms only — no containsPoint/area (undefined for a non-planar mesh). For renderer geometry that isn't flat. |
Shape = Box | Rect | Polygon | Mesh, discriminated by is* brand fields and narrowed with the exported
guards isBox / isRect / isPolygon / isMesh.
Coordinate convention: x increases left→right, y increases top→bottom (screen, y-down). Rotation
is in radians, positive = clockwise, uniform across every primitive and helper (Rect, Polygon,
Mesh, and the point*/line* angle functions). elevation (and Point.z) is the out-of-plane height;
positive is toward the viewer. Spatial operations are computed in the xy plane — distances, angles,
areas, and containsPoint ignore z/elevation. Intersection tests (lineIntersection,
intersectLineRect) compare elevation only when both operands define it (an undefined elevation —
a Box, or a bare LineLike — means "any height" and always intersects); when both are defined they
intersect only within an elevationTolerance (default 1e-3), and the result point's z is the shared
elevation.
Design decisions
2.5D: 2D shapes + elevation, one 3D Mesh
Point is the only inherently-3D type (a vector). Every shape is 2D positioned by elevation (Box
has none — it is infinite in z). Polygon is planar, so its containsPoint/area are exact; genuinely
3D geometry uses Mesh, which deliberately omits those operations (a "contains point" on a non-planar
mesh would only be an xy-silhouette test).
Immutable by default, with set mutators
Shapes are immutable value objects: getters return readonly views and every transform returns a new
instance, so shared geometry (e.g. a booth's rect) can't be mutated out from under another reader. Each
primitive (Point, Line, Box, Rect, Polygon, Mesh) exposes a single public, docs-flagged
set(...) mutator — the escape hatch the transforms write through, and the way to update fields like
elevation in place.
Opt-in mutation via a target parameter
Because immutable transforms allocate, every transform also accepts an optional target to write the
result into instead of allocating — the same idiom three.js uses, but inverted so the default is pure:
const moved = box.translate(offset);
box.translate(offset, box);
box.translate(offset, scratch);
Free functions read all inputs before writing the target, so passing the input as the target
(f(x, …, x)) is safe. Use target only where profiling shows allocation matters.
Classes + free functions (the duality)
Every operation is a pure free function over structural *Like inputs, and each class exposes a
thin method that forwards to it:
lineLength(line);
line.length();
boxTranslate(box, offset);
box.translate(offset);
Free functions accept structural shapes (Point2Like, LineLike, RectLike, MeshLike, …), so callers
don't need class instances and there is no class-identity coupling. Methods give discoverability and
chaining.
Transform/merge logic defined once (Polygon reuses Mesh)
Polygon structurally satisfies MeshLike (its 2D vertices satisfy PointLike), so the transform and
merge logic lives once on the mesh free functions — meshTranslate / meshScale / meshRotate /
meshMerge (plus meshBounds). Both Mesh's and Polygon's methods forward to them via the optional
target: a Polygon target keeps the result a Polygon (elevation preserved), a Mesh target keeps it
a Mesh.
Structural input types
Inputs are the loose *Like types (Point2Like = { x, y }, RectLike = { center, size, rotation?, elevation? }, MeshLike, PolygonLike, …); outputs are concrete class instances. Ops like translate /
expand / scale take number | Point2Like (planar shapes such as Box) or number | PointLike (the
3D Point ops) — a scalar broadcasts to both axes, a point gives per-axis values.
Brands instead of instanceof
Each primitive carries a readonly is<Name> = true field (three.js style) so type discrimination
survives duplicate module copies and serialization. The Shape guards check the brand rather than
instanceof, and accept only a Shape (a stray { isBox: true } object cannot masquerade as a box).
Geographic (geo)
The geo module provides pure geodetic math on a WGS-84 sphere. It is a separate
coordinate space from the planar primitives above — do not mix them without an
explicit bridge.
LatLng | { lat: number; lng: number } — decimal degrees; lat north-positive, lng east-positive |
GeoAnchor | { local: Point2Like; geo: LatLng } — a correspondence used by the projection bridge |
haversineDistance(a, b) | Great-circle distance in metres |
bearing(from, to) | Initial compass bearing in degrees [0, 360) |
destinationPoint(from, distanceM, bearingDeg) | Destination LatLng given origin, distance, and bearing |
projectLocalToGps(point, a, b) | Maps a local Point2Like to LatLng via two anchors |
projectGpsToLocal(geo, a, b) | Maps a LatLng to a local Point2Like via two anchors — inverse of above |
Convention (different from the planar primitives):
- Coordinates are in degrees (not radians).
- Bearings are compass: 0 = North, 90 = East, 180 = South, 270 = West, increasing clockwise.
- Earth radius constant: 6 371 000 m (WGS-84 mean radius).
This is deliberately firewalled from the planar convention (angles in radians,
positive-clockwise starting from the +x axis, y-down screen coordinates,
Euclidean distance). The only bridge is projectLocalToGps / projectGpsToLocal,
which takes validated GeoAnchor inputs — no floorplan-domain logic enters geo.
Building
Run nx build geometry to build the library.
Running unit tests
Run nx test geometry to execute the unit tests via Vitest.