Linear Algebra and Geometry#
Status: implemented — std/linear and std/geometry exist and their tests pass natively; section 12 lists the
language gaps that remain (item 12 is still open) and section 13 the packages that come after.
One vector type per width, over whatever number the program counts in. This is the specification of std/linear and
std/geometry, of the scalar tower they stand on, and of the rule that decides which member exists for which scalar.
Nothing here is a new feature of the language: the types are ordinary types, the layering is ordinary conditional
extend, and the operators are the traits they already were.
Numeric Signed Real the bound
+ - * / % == < unary -, absolute squareRoot, sine, ... what it adds
────────────────────────────────────────────────────────────────
dot, lengthSquared negate, absolute length, normalized Vector2, Vector3, Vector4
cross, min, max manhattanLength angle, rotated, lerp
Rectangle, Box perpendicular Circle, Sphere, Ray, Plane
────────────────────────────────────────────────────────────────
Int, Int32, UInt, ... Int, Int32, Float Float64, Fixed who carries it- 1. The scalar tower —
Numeric,Signed,Real, andFixed - 2. The types
- 3. Which member lives under which bound — the table
- 4. Naming
- 5. Literals and inference
- 6. Operators — and the three products that are methods
- 7. Conventions — handedness, columns, angles, and what the library must not assume
- 8. Numeric policy — overflow, comparison, determinism
- 9. What is deliberately not in
- 10.
Fixed, the deterministic scalar - 11. Where this comes from — glam, nalgebra, cgmath, euclid, Godot, Unity, simd, GLM
- 12. What the language and the compiler must provide
- 13. The package cut for what follows
- 14. Open questions
1. The scalar tower#
There is one Vector2, and its scalar is a type parameter with a bound and a default.
public type Vector2<Scalar: Numeric = Float> with Add, Subtract, Multiply<Scalar>, Divide<Scalar>Vector2 is the float vector, Vector2<Int> the pixel or tile vector, Vector2<Fixed> the deterministic one. There is
no Vector2i and no Vector2Int: without macros a twin type is a hand-maintained copy, and an abbreviation in a name is
not a name.
What each instantiation can do is decided by three bounds, and the bounds are the library's whole structure:
| Bound | Declared in | What it adds | Who carries it |
|---|---|---|---|
Numeric | std/number | + - * / %, ==, <, Show, TryFrom<String, _> | every integer type, Float32, Float64, Decimal, Fixed |
Signed | std/number | unary -, absolute | the signed integers, the floats, Fixed |
Real | std/number (new) | the constants pi, tau and epsilon, squareRoot, the eight trigonometric functions, floor/ceiling/round, halved, the two degree conversions | Float64, Fixed |
Real is the line this library is built along. Everything that is arithmetic alone — dot, lengthSquared, cross,
min, max, clamped, a Manhattan length, a rectangle intersection, a tile lookup — lives under Numeric, so a grid
vector has it. Everything that needs a root or an angle — length, normalized, distanceTo, angle, rotated,
interpolation, circles, matrices with rotations, quaternions — lives under Real, so a grid vector cannot reach it
and never silently rounds.
The full shape of Real:
public trait Real with Signed {
static pi: Self
static tau: Self
static epsilon: Self
fn squareRoot(): Self
fn sine(): Self
fn cosine(): Self
fn tangent(): Self
fn arcSine(): Self
fn arcCosine(): Self
fn arcTangent(): Self
fn arcTangentDivided(by: Self): Self
fn floor(): Self
fn ceiling(): Self
fn round(): Self
fn halved(): Self
fn radiansOfDegrees(): Self
fn degreesOfRadians(): Self
fn doubled(): Self { self + self }
}Three of those need a word.
halved and doubled are not mathematics, they are a workaround, and they are named so that they read as
members rather than as a hack. A body that is generic over its scalar cannot write a numeric literal at all: a literal
has a type, and inside extend<Scalar: Real> Vector2<Scalar> that type is Scalar, which no back end substitutes (see
section 12). So size / 2 cannot be written, 1 cannot be
written, and the midpoint of a rectangle, the bottom row of a transformation matrix and a "full" interpolation factor all
need one of the two constants. Both are answered now. A literal adapts to the parameter (item 14 of section 12), and Numeric
requires static zero: Self and static one: Self of every number type (item 10), so a generic body writes
Scalar.zero and Scalar.one - the free function zeroOf(value) that derived the zero as value - value is
deleted, and so is the member unit() that asked a value of the scalar for its one: every caller reads Scalar.one.
radiansOfDegrees and degreesOfRadians are members of the scalar and not functions of std/linear for the same
reason: the conversion needs the value of pi in this scalar's type, and 180 cannot be written in a generic body
either. Each implementor carries its own exact factor, which for Fixed is a precomputed count of parts rather than a
division.
Real requires the constants pi, tau and epsilon (static pi: Self, section 12 item 10), under the
names the scalars already carried them by, so nothing was renamed: Float64.pi and Fixed.pi, Float64.tau and
Fixed.tau, and epsilon, the gap between one and the next value the scalar holds - two to the power of minus 52 for
a Float64, one part (Fixed.step) for a Fixed. tau is its own constant and not pi.doubled(), because it is
rounded once in the scalar's type: for Fixed the two differ by a part. A generic body reads Scalar.pi, so the turns
of Angle are one declaration for every scalar and Circle.area and circumference read Scalar.pi and
Scalar.tau; std/geometry's halfTurnOf, which took arcCosine of minus one because nothing else could name pi,
is deleted.
2. The types#
std/linear:
| Type | Fields | Default scalar | Bound |
|---|---|---|---|
Fixed | parts: Int64 | — | carries Real |
Angle<Scalar> | radians | Float | Real |
Vector2<Scalar> | x, y | Float | Numeric |
Vector3<Scalar> | x, y, z | Float | Numeric |
Vector4<Scalar> | x, y, z, w | Float | Numeric |
Matrix2<Scalar> | xAxis, yAxis (each a Vector2) | Float | Numeric |
Matrix3<Scalar> | xAxis, yAxis, zAxis (each a Vector3) | Float | Numeric |
Matrix4<Scalar> | xAxis, yAxis, zAxis, wAxis (each a Vector4) | Float | Numeric |
Quaternion<Scalar> | x, y, z, w | Float | Real |
std/geometry:
| Type | Fields | Bound | Notes |
|---|---|---|---|
Rectangle<Scalar> | origin, size | Numeric | half-open |
Circle<Scalar> | center, radius | Real | closed |
Segment2<Scalar> | from, to | Numeric | both ends belong to it |
Ray2<Scalar> | origin, direction | Real | hits answer a distance |
Triangle2<Scalar> | first, second, third | Numeric | winding not fixed |
Polygon<Scalar> | corners: List<Vector2<Scalar>> | Numeric | convex tests first |
Box<Scalar> | origin, size | Numeric | half-open |
Sphere<Scalar> | center, radius | Real | closed |
Ray3<Scalar> | origin, direction | Real | hits answer a distance |
Plane<Scalar> | normal, distance | Real | normal of length one |
Triangle3<Scalar> | first, second, third | Numeric | normal needs Real |
Matrix2 is in. It is the linear part of a 2D transformation — rotation, scale, shear, no translation — it is what
Matrix3.affine takes and Matrix3.linearPart answers, and it costs two vectors.
A matrix stores its columns as vectors, not as a flat array. Array<Item, const Size: Int> was the alternative and a
probe settled it: Array is implemented by no back end — stage 0's interpreter answered Unknown name Array while
it existed, and the C back end's manifest marks every member of it planned — so nothing that uses one runs today. Beyond that, columns as vectors is
the better design: a column is where a basis vector lands, so matrix.xAxis is the answer to a question a caller
actually asks, matrix.applied(to: unitX) and matrix.xAxis are the same value, and every column operation is vector
arithmetic that already exists. at(row, column) is there for the loop that wants it, and column(index) and
row(index) for the two directions.
3. Which member lives under which bound#
Short form; the docblocks are the full list.
Under Numeric | Under Signed | Under Real |
|---|---|---|
add, subtract, multiply, divide (by a scalar) | negate | length, distanceTo, distanceSquaredTo |
dot, lengthSquared, cross | absolute | normalized, withLength |
scaled(by:), divided(by:) (component-wise) | manhattanLength, manhattanDistanceTo | angle, angleTo, rotated, rotatedAround |
min, max, clamped, withX/withY/withZ | perpendicular (2D) | interpolated, projectedOnto, reflected, isCloseTo |
filled, isZero, sum, largestComponent | ||
Matrix.transposed, determinant, multiply, applied(to:), at, column, row, scaling, affine, translation, transformedPoint | Matrix.negate | Matrix.rotation, inverse, inverseAffine, isCloseTo |
Rectangle/Box: contains, encloses, intersects, intersection, combined, covering, translated, grown, closestPoint, bounds, area/volume, isEmpty | manhattanDistanceTo | center, centered, distanceTo, distanceSquaredTo |
Segment2: step, sideOf, bounds, intersects | manhattanLength | length, direction, center, at, closestPoint, distanceTo, intersection |
Triangle2: signedArea, bounds, edges, contains, translated | doubledArea | area, center, closestPoint, distanceTo |
Polygon: edges, signedDoubledArea, bounds, isConvex, contains, containsConvex, intersectsConvex | doubledArea | area |
Triangle3: firstEdge, secondEdge, doubledAreaVector, bounds | normal, area, plane, center, weightsOf, contains | |
— (the whole type needs Real) | Circle, Sphere, Ray2, Ray3, Plane, Quaternion, Angle |
One entry in that table is a compromise and says so in its # Open:
Rectangle.centerneedsRealbecause it halves, and the midpoint of a whole-number rectangle is not a whole number. The library does not pick a rounding for a program.
4. Naming#
- Full words.
squareRoot, notsqrt.lengthSquared, notlengthSq.arcTangentDivided(by:), notatan2— and it takes a label, becauseyandxin that order is the one signature everybody gets wrong. - A method that answers a changed copy is a participle or a noun:
normalized,rotated,translated,grown,transposed,floored,rounded,clamped,interpolated,combined,covering,flipped,mirrored.perpendicular,determinant,bounds,center,area,ceilingare nouns. - A question is
is.../has...:isEmpty,isZero,isConvex,isInFront,isCloseTo. - A transition between scalar kinds is explicit and named after its target:
Vector2<Int>.toFloat(),Vector2<Int>.toFixed(),Vector2<Float>.rounded(),.floored(),.ceiling(),Fixed.toFloat(),Fixed.toInt(),Fixed.approximating(aFloat). There is no implicit conversion anywhere. Segment2carries the digit thatRay2/Ray3andTriangle2/Triangle3carry, because a segment of space has no word of its own the wayRectangle/BoxandCircle/Spherehave one. ASegment3therefore lands beside it rather than renaming it.- A conversion is never given the same name in two instantiations of one type.
toFloatexists onVector2<Int>and nowhere else,toFixedonVector2<Int>and nowhere else,rounded/floored/ceilingonVector2<Float>and nowhere else. The reason was stage 0's interpreter, which had no types and therefore could not tellextend Vector2<Int>fromextend Vector2<Fixed>; twotoFloats would have made one of them silently answer the other's arithmetic. Stage 0 is gone and the VM of milestone 7 reads the typed IR, so the trap is gone with it; the names stay, because each one says what it converts to. The constants (zero,one,unitX,unitY) are one declaration for every instantiation, overScalar.zeroandScalar.one.
5. Literals and inference#
Vector2(1, 2) is a Vector2<Int>; Vector2(1.0, 2.0) is a Vector2<Float>. The literals decide, through ordinary
inference, and the two do not mix:
const tile = Vector2(3, 4) // Vector2<Int>
const step = Vector2(3.0, 4.0) // Vector2<Float>
const exact = Vector2(Fixed.from(3), Fixed.from(4))
const crossed = tile.toFloat().add(step)Three inference rules are worth knowing, and two of them are sharp edges:
- A literal adapts to a declared scalar.
const size: Vector2<Float> = Vector2(1, 2)works, because the annotation saysFloat64and1adapts to it. - A constant takes its scalar from the expected type, and from the declared default where nothing expects one.
The constants (
zero,one,unitX, ...,identity) are declared once, for every scalar, over the scalar's ownScalar.zeroandScalar.one:const tile: Vector2<Int> = Vector2.zerois the grid's zero,Vector2<Fixed>.zerothe deterministic one, and a bareprint Vector2.zerotheFloatone. - A
constbinding of a generic result may need an annotation. Insideextend<Scalar: Real> Vector2<Scalar>,const size = self.length()does not infer, andconst size: Scalar = self.length()does. A result position infers fine; only a bare binding does not.
6. Operators#
The operators are the traits they already are, and the vectors and matrices come with them:
| Written | Trait | On |
|---|---|---|
a + b | Add | vector + vector, matrix + matrix, angle + angle |
a - b | Subtract | the same three |
-a | Negate | a vector, a matrix or a quaternion of a Signed scalar, conditionally |
a * s | Multiply<Scalar> | vector times scalar, angle times scalar |
a / s | Divide<Scalar> | vector by scalar |
a * b | Multiply | matrix times matrix, quaternion times quaternion — the composition |
a == b | Equals | every type, generated from the fields |
a < b | Compare | Fixed and Angle |
Negate is the pattern worth copying: it is not on the type, it is a conditional implementation
extend<Scalar: Signed> Vector2<Scalar> with Negate, because Vector2<UInt> has no unary minus and saying so in the
bound is better than a body that panics.
Three products are methods and not operators, each for a reason:
matrix.applied(to: vector). A type has one namespace of members, andmultiplyis already the composition of two matrices. TwoMultiplyimplementations with differentOtherare legal as declarations and coherent, and neither the member lookup at a call nor the operator resolution finds the second one — so writing both would be an API nothing can reach.applied(to:)also reads better in a chain:projection.multiply(view).applied(to: point).vector.scaled(by: otherVector)for the component-wise product, because*on two vectors means the dot product to half of the world and the component-wise product to the other half, and neither half is wrong.scalar * vectoris not provided. It would beextend Float64 with Multiply<Vector2<Float64>, Vector2<Float64>>instd/linear. The orphan rule allows it (the package ownsVector2, a type named as an argument of the trait), and the operator form lowers as that implementation since #440. It is a library choice: every numeric package would reach intoFloat64with an implementation of its own. Writevector * scalar.
In a compiled program the operator symbols on these types do not work yet, because the back end does not lower an
operator whose implementation belongs to a generic type. The trait method is the same call and does work
(a.add(b), a.multiply(factor), a.negate()), the checker and the interpreter accept both, and the native gate
programs are written in the method form for exactly this reason. It is the first item of
section 12.
7. Conventions#
Nothing here knows which way is up. y is the second component and z is the third. A program that draws with y
growing downwards and one that draws with y growing upwards use the same vectors, the same rectangles and the same
rotations; only what a viewer sees differs. Concretely, the library never has a member called up, down, left,
right, top or bottom, and Rectangle says minimum/maximum and origin/size rather than top/bottom.
| Question | Answer | Why |
|---|---|---|
| Handedness in 3D | right-handed: unitX.cross(unitY) == unitZ | the convention of mathematics, of OpenGL's classic pipeline and of glam/nalgebra/cgmath; a left-handed world is one negated axis away |
| Vectors | columns | matrix * vector, so a composition reads right to left as it does in mathematics: a * b is "a after b" |
| Matrix storage | the columns, each as a vector | a column is where a basis vector lands; this is also what a graphics API means by "column-major", so a buffer upload is the fields in order |
| Angles | radians, always, wrapped in Angle | one unit inside, and the two places where the other unit appears are Angle.degrees and toDegrees |
| Rotation direction | from the first axis towards the second: a quarter turn takes unitX onto unitY | it is the only statement that does not need to know which way y points; whether a viewer calls it clockwise is the program's own business |
| Rectangles and boxes | half-open: the minimum edge is inside, the maximum edge is outside | so that a row of them tiles: Rectangle(Vector2(0, 0), Vector2(8, 8)) and Rectangle(Vector2(8, 0), Vector2(8, 8)) share no cell, and a hit test never answers twice |
| Circles and spheres | closed: the boundary is inside | they do not tile, so nothing is lost by counting a boundary twice, and a great deal is lost by leaving a touching point out of both |
| Bounding volumes | closed hulls | bounds and covering answer the box whose corners hold the shape, so a corner can lie exactly on a maximum edge and contains then answers false for it. A bound is a bound; the half-open rule is about tiling |
| Rays | a hit answers how far along the ray, not the point | that is what a caller compares to find the nearest of several hits; ray.at(distance) turns it back into a point |
8. Numeric policy#
- Overflow panics, here as everywhere in the language.
lengthSquaredon aVector2<Int>squares both components, so a pair of coordinates above about three billion leaves the range of anIntalthough the vector itself is ordinary. That is a bug in the program and the panic says so.Fixedmultiplies before it scales back down, so its real limit is about 46341 and notFixed.largest; both are in the docblocks. - Integer division truncates towards zero and a division by zero panics, because that is what
Dividesays.Rectangle<Int>therefore has nocenter. - Floats are compared with a tolerance.
Float64.isCloseTo(other, tolerance:)already exists instd/numberwith a default of0.000001, and every shape and vector here carriesisCloseTo(other, tolerance:)with the tolerance required — a tolerance that is right for a unit vector is wrong for a world coordinate, and a default would hide which one was meant.Fixed.isCloseTodefaults to 66 parts, about a thousandth. - A zero-length vector normalizes to itself rather than panicking or answering
nan. There is no direction to keep, and the honest answer is the one the caller can test. - No
nanreaches a comparison here.Float64has a totalcomparewithnanabove everything, andFixedhas nonanat all — which is one more reason a lockstep simulation runs on it. arcSineandarcCosinepanic outside[-1, 1], in both implementors ofReal(decided 2026-09-26).Fixedcannot answernan, andFloat64used to answer C'snanthere, so a body generic over the scalar stopped for one and computed on with a poisoned value for the other. The precondition is an ordinary expression (value.absolute() <= Scalar.one), which is exactly where PANICS.md's principle allows a panic and where a wrong number that keeps running is the worse answer (its variant E).Float64.arcSineis therefore TorbScript overarcTangentDividedwith the check in front,nanstill answersnan, andvalue.clamp(-Scalar.one, Scalar.one)is the total form for a ratio that rounding pushed past one. The other members where the two differ -squareRootandnaturalLogarithmbelow their domain - are "what the implementor documents" inRealand stay so.Fixedis bit-identical everywhere. Every operation on it, the square root and the trigonometry included, is integer arithmetic. The native gate programslinear.trb,geometry.trbandgrid-vectors.trbcompare the compiled binary against their.expectedbyte for byte (they compared stage 0 against the binary until stage 0 was deleted), the VM joins that comparison in milestone 7, and that is the guarantee written down as a test.
9. What is deliberately not in#
- No SIMD intrinsics and no alignment padding.
Vector4<Float>is four doubles and nothing else. AVector4that was secretly 32 bytes wide would makeVector3a lie, and vectorization is the back end's job — the C back end can recognise the loops, and until it does the cost is honest rather than hidden. - No swizzles. There is no
.xy,.xzyor.rgba. Each of them is a name per permutation (fifty for four components) with no way to generate them, andVector2(v.x, v.z)says the same thing in the same number of characters.toVector2(),toVector3()andwithX/withY/withZcover what is actually used. - No implicit conversions.
Vector2<Int>andVector2<Float>do not mix, and no arithmetic widens on its own. - No
Frombetween two instantiations.extend<Target, Source> Vector2<Target> with From<Vector2<Source>> where Target: From<Source>type checks and does not collide with the blanketInto, which answers the coherence question the plan asked — but the static member of a trait cannot be called through a type parameter, so nothing could reach it. The named conversions (section 4) are also the better API:toFloat()says which direction it goes. - No projection matrices. A projection depends on the clip space of the API that consumes it — how deep it is and which way it points — and this package knows nothing about an API. They belong where that convention is known.
- No triangulation and no convex decomposition. Both are algorithms rather than geometry, both want a scratch buffer, and a mesh library is where they earn their place.
- No colour.
std/coloris its own package: a colour is not a vector, it has a space, andVector4as a colour is the mistake every graphics library regrets.
10. Fixed, the deterministic scalar#
Q16.16 in an Int64. The field is parts, a count of 1/65536, so Fixed(65536) is one and Fixed(1) is the
smallest step. Values run to about ±1.4e14 with a resolution of 1.5e-5.
Why not Q32.32. A Q32.32 multiplication needs (a * b) >> 32 over a 128-bit intermediate, and the language has no
128-bit integer. It can be emulated with UInt64.multipliedWrapping on split halves, which is thirty lines of
sign-careful bit work that no test in the repository exercises — for a type whose entire purpose is being trustworthy,
that is the wrong trade. Q16.16 multiplies as parts * other.parts / 65536 in one Int64, which is exact until the
intermediate overflows at about 46341, and an overflow panics rather than wrapping. A world of 46000 units at 1.5e-5
resolution is a large game.
No bit operations. Every shift is written as a division or a multiplication by a power of two. That is not
aesthetics: the interpreter has no Bits member at all (shiftedLeft on an Int answers "has no method"), so a
Fixed built on shifts would run in no test and in no gate program. Truncating division and an arithmetic shift differ
for negative values, and the library uses division consistently, which is what makes both stages agree.
squareRoot is Newton's method on integers over parts * 65536, which answers the largest value whose square is at
most the input — so it is exact for a square and one part low at worst. It panics above about 2147483648, where the
scaling up overflows.
sine and cosine are CORDIC: sixteen rotations by a fixed angle each, where every rotation is an addition and a
division by a power of two. No table of sines, no series, no floating point. The angle is first reduced into
[-pi/2, pi/2] with a sign flip for the cosine, the vector starts at the reciprocal of the rotation's gain (39797
parts) and ends at one, and the sixteen step angles are arcTangent(2^-i) in parts. The divisions round to nearest
rather than towards zero, because sixteen truncations pull every vector towards the origin and add up to a visible
error; with rounding the answers are within about two parts of the true value, which the tests pin down.
arcTangentDivided(by:) is the same machine run the other way: the rotations that drive the vertical component to
zero add up to the angle. arcSine goes through it with squareRoot(1 - x²) as the other component, and arcCosine is
a quarter turn minus that.
show writes the exact decimal. A Fixed is a multiple of 1/65536, so its expansion always ends, after at most
sixteen digits — and Fixed.tryFrom("0.1") shows as 0.100006103515625, because that is the number. parse reads at most
nine digits after the point and rounds to the nearest part.
What this buys. Vector2<Fixed>, Rectangle<Fixed>, Circle<Fixed>, Ray3<Fixed>, Matrix4<Fixed> and
Quaternion<Fixed> all work with no change to a single line of the generic code — the three native gate programs are
the proof, and they are byte-identical between the interpreter and the compiled binary. It also buys the only
trigonometry this repository can currently run at all in the interpreter, since Float64's implementation of Real
lives in std/number, which the interpreter does not load.
11. Where this comes from#
| Library | What it got right | What hurt | What we take |
|---|---|---|---|
| glam (Rust) | columns as Vec4 fields; Affine3A separate from Mat4; one obvious type per job | Vec3/Vec3A twins for alignment; feature flags decide f32 vs f64; no generic scalar, so DVec3 is a macro-generated copy | columns as vector fields; the affine form as its own constructor rather than its own type |
| nalgebra (Rust) | one Matrix<T, R, C, S> for everything, statically sized; Unit<T> for "this is normalized" | the type signatures are unreadable; compile times; a beginner cannot spell the type of a 3-vector | the idea of encoding "normalized" in the type is noted and not taken: it doubles the API for a property one normalized() call establishes |
| cgmath (Rust) | Rad/Deg newtypes for angles — the bug it prevents is real | two angle types plus an Angle trait is one too many; InnerSpace/VectorSpace/MetricSpace traits are more layering than payoff | one Angle wrapper, radians inside, degrees at the boundary |
| euclid (Rust, Servo) | units as a phantom type parameter, so a Point2D<f32, ScreenSpace> cannot be added to a world point; Point and Vector are different types | two type parameters on every signature; the space parameter infects every function | the units idea is the strongest thing on this list and is deferred to std/transform, where a coordinate space is the subject |
Godot (Vector2/Vector2i) | a first-class integer vector, which is what a tilemap and a pixel actually need | twin types, hand-maintained, and the method sets drift apart | the integer vector is first class here too — as Vector2<Int>, one declaration |
| Unity.Mathematics | shader-like float2/float3, swizzles, SIMD-backed | lowercase type names fight every other convention; fifty swizzle properties; float3 is not Vector3 and the two coexist forever | nothing, except the confirmation that swizzles are a cost |
Swift simd | operators on tuples-of-floats with hardware backing; simd_quatf separate from vectors | fixed widths only; the type names leak the register width | the quaternion as its own type, never four loose floats |
| GLM (C++) | mirrors GLSL exactly, so shader code and host code read alike | glm::vec3 is float only unless you reach for tvec3; the GLSL mirror brings swizzles and implicit conversions | the "host and shader read alike" goal, to be paid for in std/gpu by generating the shader rather than by imitating it |
Two things nobody on that list has and this design does: one generic type instead of twin types, paid for by the scalar tower rather than by macros, and a fixed-point scalar that carries the same trait as the float, which makes every line of geometry deterministic by changing one type argument.
12. What the language and the compiler must provide#
In the order it hurt, each with a reproduction. examples/generic-scalar/ was the reproduction of the generic ones;
since items 2 and 14 closed it builds and runs natively and prints what it should, so it stays as the lines that
would say so if they came back. The rest quote the diagnostic that is the reproduction. Statuses re-verified on
2026-09-22 with probes in tests/language/ and a native build of the example.
- ~~An operator on a generic type does not lower.~~ Closed.
a + bisAdd.add, and whichaddruns is decided by the operand and not by the implementation that declares it:extend<Scalar: Signed> Vector2<Scalar> with Negatenames a target that still holds a parameter, while the value in front of the operator carries the enclosing instance's arguments.operandTypeOfincompiler/src/ir/lower/call.trbtherefore prefers the written operand wherever the target is not closed, and the operator form and the method form of one call are the same function - inside a generic body as well.tests/conformance/generic-operators.trbis the gate. - ~~A numeric literal in a generic body is never adapted to the parameter.~~ Closed with item 14.
doubledinexamples/generic-scalar/src/main.trbbuilds natively and prints6 3.0. What it said:fn oneOf<Scalar: Numeric>(): Scalar { 1 }type checks, the checker recordsInt64for the literal, and the back end then reports "the function returnsFloat64andreturncarriesInt64" for theFloat64instance. The substitution is not what is missing - there is no parameter in the recorded type to substitute - so this is the checker's half of item 14, and it closes there: the literal has to adapt to the parameter, and the back end then does with it exactly what it does withconst x: Float = 1, which compiles today. Everything in section 1 abouthalvedand the deletedunitandzeroOfexisted only because of this. Reproduction:examples/generic-scalar/src/main.trb,doubled. - ~~A
constmember of a generic type is not instantiated per type argument.~~ Closed in the back end.Box<Int>.emptyandBox<String>.emptyare one declaration and two values, and a binary keeps one cell per instance, named after the arguments the read decided. Stage 0's interpreter answered the same value for every scalar while it existed, which was item 6 seen from another side and is why the constants of this library live in concreteextends.tests/conformance/generic-constants.trbis the gate. - ~~A type parameter's default is not used to reach a member of a concrete
extend.~~ Closed. A barePair.zerowithItem: Numeric = Floatchecks and runs natively, printingPair(value: 0.0). What it said:Vector2.zerowherezerois declared inextend Vector2<Float>reports "Cannot inferScalarofVector2" even with the annotationconst origin: Vector2<Float> = Vector2.zero;Vector2<Float>.zeroworks. Since the declaration saysScalar: Numeric = Float, the bare name has an answer. Smallest change: apply the declared defaults where a member access on a bare type name has nothing else to go on. Reproduction:examples/generic-scalar/src/main.trb,extend Pair<Float>. - A package may only import what its manifest names as a dependency.
use Vector2 from "std/linear"answersUnknown name Vector2in a package that does not declarestd/linearthere - which is every package ofstd/testing a sibling, and the workspace oftests/conformance/, since none of them declares a dependency at all (a conformance program is one file with no manifest of its own). Everything ofstd/they need therefore imports by path, and astd/package can only call functions from its own directory. That is whystd/geometryreachesstd/linearas"../../linear/src/vector2"; the copy ofzeroOfeach of the two packages held is gone with item 10, because the zero isNumeric.zeronow. - ~~The interpreter cannot tell two instantiations of one
extendapart.~~ Gone with stage 0. Stage 0's untyped interpreter answeredVector2(x: 1.0, y: 0.0)forVector2<Int>.unitX, becauseextend Vector2<Float>was declared first. The VM of milestone 7 reads the typed IR and cannot make that mistake; the library still keeps every conversion under a name of its own (section 4), because each name says what it converts to. - A static member cannot be reached through a type parameter.
Scalar.zero()inside a generic body answeredUnknown name Scalaron stage 0 (gone), andVector2.from(other)throughFromreported "a call of a trait member without a receiver" in the back end (not re-verified on 2026-09-22). A constant is reached through a parameter since item 10 closed (Scalar.zero); astatic fnthroughFromis what this item still is, and it keeps aFrombetween two instantiations unreachable. Float32cannot carryRealyet. Its arithmetic,==,compareandShowrun in both back ends, rounded to binary32 after every operation, andFloat32 with TryFrom<Float64, NumberRangeError>narrows aFloat64to the nearest one (both 2026-10-12, issue #35). What is left isRealitself: nosquareRootand none of the angle members exist for aFloat32. Smallest change: oneextend Float32 with Realover thefloatfunctions of<math.h>(sqrtf,sinf,powf, ...), as natives of their own.Array<Item, const Size: Int>runs nowhere. Stage 0 answeredUnknown name Arraywhile it existed, and the C back end marks every member planned. It is the reason a matrix is fields and not storage, and it will be the reasonstd/tensorwaits forBuffer<Item>.- ~~A trait cannot require a constant.~~ Closed (2026-09-23). A trait declares
static zero: Selfwithout a value - a requirement every implementation provides as a constant of its own, in the type, in theextendthat implements the trait or in any otherextendof the type - or with a value, a default an implementation may replace. A generic body reads it through its parameter (Scalar.zero), and the lowering reads the constant of the type the parameter stands for in each instance, or instantiates the default withSelfbound to it (reachedConstantOfincompiler/src/ir/constant.trb, the same questionproviderOfMemberasks for a table).Numericrequireszeroandone, every number type ofstd/numberandFixedprovide them,zeroOfis deleted fromstd/linearandstd/geometry, and the constants of the vectors, matrices, quaternions and angles are one declaration per type for every scalar. A constant without a value outside a trait is "A constant of the type needs a value"; astatic fndoes not stand in for a required constant.Realrequirespi,tauandepsilonas well, under the names the scalars already used (section 1), andMatrix3.affine,Matrix4.translationand the other affine forms moved from theRealextension down toNumeric, since a one was all they needed. Gates:compiler/tests/traits.test.trb("A constant a trait requires"),tests/conformance/trait-constants.trb,examples/generic-scalar. - A member-level
whereclause on a method of a generictypeadds nothing.fn manhattanLength(): Scalar where Scalar: Signedinsidetype Vector2<Scalar: Numeric>reports "Scalarhas no memberabsolute" in its own body, although the documentation describes exactly that form for a trait member. The library uses a conditionalextendinstead, which is the better spelling anyway. - Two
Multiplyimplementations on one type are unreachable.extend<Scalar> Matrix2<Scalar> with Multiply<Vector2<Scalar>, Vector2<Scalar>>next towith Multiplyon the type is accepted and coherent, and thenmatrix.multiply(vector)reports "ExpectedMatrix2<Float64>, foundVector2<Float64>" whilematrix * vectorreports "Matrix2<Float64>does not implementMultiply". Overloading by a trait parameter is one of the two overload forms the decision log keeps, so a call should find it. Partly closed (2026-09-22): the checker now resolves the operator form (scale * vectorbesidescale * scaletype checks), while the method formscale.multiply(vector)is still refused, because one member name reaches the first instantiation (docs/design/COLLECTIONS.mdgap 4). Natively the two bodies are two functions since item 16 closed. - Two small checker reports.
const size = self.length()inside a conditionalextenddoes not infer the parameter although the annotation form does; andprint(x).round()answers "The checker did not work out the type of this expression — this is a bug of the compiler" where it means "Voidhas no memberround". - ~~A type parameter accepts a value of any type at all.~~ Closed. A
Stringor an unadaptedInt64returned as a type parameter is refused ("ExpectedItem, foundString", "ExpectedItem, foundInt64"), and a literal against aNumericparameter adapts to it. What it said:fn oneOf<Item: Numeric>(): Item { 1 }type checks, and so doesfn textOf<Item: Numeric>(): Item { "x" }andfn boundless<Item>(): Item { 1 }. The literal is not adapted to the parameter - the checker recordsInt64for it - and the return is then accepted againstItemalthough nothing makes it one. That is the real cause of item 2: the back end has no fact to substitute, and the IR verifier is what catches the program. The checker has to record the parameter as the literal's adapted type and reject the others; what an integer literal means at a user implementor ofNumericsuch asFixedis a language question that has no answer yet, so that instance stays a finding of the back end until it has one. ==on a generic type records no member for some instantiations.Vector2<Float>.equalsis not found bylookupMember, sorecordTraitCallrecords nothing and the lowering has no resolution to read;Vector2<Int>has one. The smallest reproduction is a generic type plus a concreteextendof it whose body constructs the type. The lowering answers it the way it answers a tuple's structural comparison - it asksdispatchedOnitself - so no program is blocked by it, and the missing record is still a record the checker owes.- ~~Two
Fromimplementations on one type collide in the back end.~~ Closed (2026-09-22). The name of a member of anextendwhose trait has arguments carries the applied trait as one more owner (symbolPathOfincompiler/src/ir/instantiate.trb), soextend Path with From<String>next toextend Path with From<Name>are two functions, and so areBoard * ScalebesideBoard * Board(tests/conformance/implementation-trait-arguments.trb). What it said: both were one mangled name,Path.fromcarried no arguments of its own, and the C compiler rejected the second prototype.
13. The package cut for what follows#
std/transform. Transform2 and Transform3 as a decomposed transformation — translation, rotation
(Angle or Quaternion), scale — rather than a matrix, because that is the form an animation interpolates and a scene
graph edits, and toMatrix3/toMatrix4 is the one-way door to the shader. This is also where coordinate spaces
belong: euclid's phantom unit parameter is the strongest idea in the research table, and a Point<Space> that cannot be
added to another space's point is worth a type parameter here, where a space is the subject, and not in std/linear,
where it would infect every signature. Hierarchies are values: a parent chain is a List<Transform3> folded, and
nothing needs an identity.
std/collision. Broad phase (a sweep over bounds, then a grid or a bounding-volume hierarchy over Rectangle and
Box), narrow phase (the separating axis test Polygon.intersectsConvex already sketches, and the Gilbert-Johnson-
Keerthi distance algorithm for convex shapes in space), and swept tests — a moving Circle against a static world,
which is what a character controller is. It answers contacts — a point, a normal, a penetration depth — where
std/geometry answers Bool and a distance, and that is the whole difference between the two packages. It is where a
trait Bounds and a trait Contains earn their place, because a broad phase wants to hold shapes of different kinds in
one list.
std/curve. Polylines, quadratic and cubic Bézier curves, Catmull-Rom and B-splines, arcs; length by subdivision,
the point and the tangent at a parameter, and flattening to a polyline within a tolerance. The same curves serve a canvas
or an SVG and a motion along one, which is why it is one package and not two. Fixed matters here: a curve followed
in lockstep has to land on the same point on both machines. The name is std/curve and not std/path, because
std/path is the package of file paths (docs/design/PATH.md) and one word may mean one thing.
std/animation. Easing functions, keyframes, tracks, tweens and a state machine, over a trait Interpolate that
Vector2, Quaternion, Angle, Fixed and a colour all carry — interpolated(toward:by:) is already spelled that way
in std/linear on purpose, so the trait can adopt the existing members without renaming anything. Time is Duration
from std/time, which is a value and needs no clock.
std/color. Color as a value with a space — linear sRGB, sRGB with its transfer function, Oklab, HSL — because a
colour that does not say its space is the bug behind every wrong gradient, and a Vector4 as a colour cannot say it.
Interpolation happens in a space the caller names, conversion is explicit, and Color carries Interpolate for
std/animation.
14. Open questions#
- ~~
isCloseToorisNear?~~ Answered by the owner (2026-09-23):isCloseTo, the namestd/numberalready has. The plan saidisNear; nothing is renamed. - ~~
interpolated(toward:by:)orlerp?~~ Answered by the owner (2026-09-23):interpolated(toward:by:), the full word.lerpis what every other library calls it and an abbreviation this language does not otherwise allow. - ~~
SegmentorSegment2?~~ Decided:Segment2.Ray2/Ray3andTriangle2/Triangle3carry the digit, and a segment of space has no word of its own the way aBoxand aSpherehave one. - ~~Should the four vector constants stay on every instantiation?~~ Answered by the owner (2026-09-23): yes,
on every number type. They are one declaration per type over
Scalar.zeroandScalar.onenow, soVector2<Int>.zero,Vector2<UInt8>.zeroandVector2<Fixed>.zeroall exist, and the interpreter trap of item 6 is gone with stage 0. - ~~Should
Matrix4carry a general inverse?~~ Decided: yes.Matrix4.inverseis the general one by cofactors andinverseAffinestays beside it as the short way for the matrices a scene graph is made of. - ~~
Quaternion.interpolatedtakes the straight path and renormalizes, not the constant-speed path along the sphere.~~ Answered by the owner (2026-10-08):interpolatedstays the straight path (nlerp), the cheap defaulttrait Interpolateadopts, andQuaternion.sphericallyInterpolated(toward:by:)is the constant-speed form beside it. Measured (a model of the Q16.16 arithmetic andFloat64): theFixedarc cosine is within about2e-5of the true angle down to a dot product of1 - 2^-16, an angle of about0.0055radians (0.3 degrees), the smallest it can tell from zero;Float64is within1e-9of the angle down to1 - 2^-53. The result is within two parts of the true rotation onFixedand within1e-15onFloat64, at every angle tried, with no fallback needed except at the exact end: the weights are ratios of sines, so a wrong small angle cancels. The fallback to the straight path is therefore taken only when the sine of the angle is at mostScalar.epsilon, which is a dot product of exactly one after rounding and the one place a division by zero could happen; the dot product is clamped to[0, 1]first becausearcCosinepanics outside[-1, 1]. - ~~Does
std/linearbelong in the prelude?~~ Answered by the owner (2026-09-23): no. It stays an explicit import, which the plan asked for; a game project imports it in the files that compute with vectors.