stdlib global conventions
August 2, 2026 · View on GitHub
The single source of truth for domain / world / API semantics this vocabulary
assumes everywhere (coordinate frames, matrices, winding, units, sentinels, …).
Each convention is stated once here; the owning declaration files carry a
one-line citation (// Convention: see CONVENTIONS.md - <section>) instead of
restating it. When two files appear to disagree, this document wins — fix the
file, not this page.
Authoring style (how to write library bodies, comments, literals, formulas)
lives in STYLE_GUIDE.md — not here. Read both before editing
stdlib sources.
Product authority for world frame and winding: Ara 3D Studio. Matrix
multiply / layout understanding matches System.Numerics.Matrix4x4.
Partial operations — no generic Optional<T> (A1)
There is no generic Optional<T> / Maybe<T> in this vocabulary, and there
cannot be: Plato sum types are non-generic (generic sums are rejected, CHK306).
An operation that may fail must land on exactly one of three sanctioned
concrete styles — any fourth style is rejected in review:
- Fallback parameter for total-ish value ops:
NormalizeOr(v, fallback). - Concrete result record with a validity field for queries:
PlaneHit3D { Hit: Boolean; Point; Parameter }— the reader checks the flag. - Concrete (non-generic) sum where the classification IS the payload:
SphereSphereIntersection = Separate | ExternalTouch | OverlapCircle(...) | ....
Conversions — type-named is implicit, ToX is explicit
A one-parameter function whose name is its own concrete return type is an
implicit conversion. Nothing marks it as one: the compiler infers it from the
spelling (FunctionInstance.IsImplicitCast), the type checker feeds it into
overload resolution as a cast relation (TypeRelations.ComputeCasts), and the
C# writer emits an implicit operator for every concrete implementer. Naming a
function after a type is therefore a decision about the type system, not about
taste.
The bar is faithfulness. A type-named conversion may only be written when the result denotes the same mathematical object as its argument, re-expressed. Nothing about the value may be invented and nothing may be discarded.
The practical test is: did writing it force a choice the argument did not already contain? If the body had to pick a resolution, a cell size, a tolerance, a sample count, a rounding mode, or which diagonal to split a quad along, the conversion invents information and is not faithful. If it drops a channel, a distance metric, a bound, a parameterisation, or any structure a reader could expect to survive, it is not faithful either. Faithful conversions are re-encodings: a rotation into the quaternion that represents it, a field into a closure that evaluates it, a rigid pose into the affine transform that performs the same map.
Everything else is spelled ToX and stays explicit. ToX is the honest
name for an approximation, a sampling, a projection, or a discard: the call site
shows the reader that something was decided or lost. A ToX function is
ordinary — it is never picked up as a cast — so it may take extra parameters and
carry the choice in its signature.
Never both spellings for one (source, target) pair. One pair, one function, one name. Two names for the same conversion means one of them is wrong about whether the conversion is faithful, and a reader has no way to tell which.
Where a whole family converts to one canonical form, declare the obligation on
an interface rather than repeating a library function per type. The transform
representations do this: IAffine2D / IAffine3D / IRigid2D / IRigid3D /
IRotational3D (transforms.concepts.plato) each name the single canonical
type their implementers convert into, which is what makes composing two
different representations well defined. Members named after a type follow this
same rule inside an interface, and the writer reifies one implicit conversion
per concrete implementer. A single conversion from an odd type stays an ordinary
library function.
Because implicitness is inferred from the name, a return type can mint casts
by accident. An interface member named Length, Area or Volume returning
Number is a plain accessor; change that return type to the quantity type of
the same name (quantities.types.plato) and every implementer silently gains an
implicit conversion. The guard is the cast-inventory pin
(tests/PlatoTests/ImplicitCastInventoryTests.cs, golden file
tests/PlatoTests/implicit-cast-inventory.txt): every implicit cast the
stdlib defines is listed there, so a conversion nobody meant to create fails the
test instead of shipping. Update the golden deliberately, in the commit that
earns the new casts.
A wrapper is built, never coerced into. The C# writer mints one conversion
from a type's shape rather than from any declaration: a single-field type
UNWRAPS implicitly to its field's type. It does not wrap. Nothing coerces a
Number into a Length, an Integer into a VertexIndex, or a Vector2D
into a Direction2D — those constructions assert an invariant the source value
was never checked against, so they stay visible as new T(f) or as a named
function (FromVector normalizes, FromVectorUnchecked asserts). The language
agrees: a one-field type's wrap is a constructor, not a cast relation. Decision:
tracker/decisions/2026-08-02-single-field-mirror-unwraps-only.md.
The cast-inventory pin above covers conversions declared in Plato. What the
writer actually emits — the declared ones plus that shape-derived unwrap — has
its own pin, tests/PlatoTests/EmittedConversionInventoryTests.cs (golden
tests/PlatoTests/emitted-conversion-inventory.txt). A wrap re-appearing fails
it.
Families already decided. Implicit, because each is a re-encoding: the
transform representations onto their common ground, and every field / SDF onto
its function-valued form (fields-implicits.library.plato). Explicit, and to
stay explicit: IMesh3D.ToTriangleMesh (triangulating a polygon or quad mesh
chooses diagonals; a tetrahedral mesh keeps only its boundary; a rich mesh drops
its attributes), IPointCloud3D.ToPointCloud (drops normals, colors,
intensities), ToRegion / ToVolume on a signed distance field (keep
membership, drop the distance), the curve-to-polyline samplers (a sample count is
invented), ToPoint on a vector (a displacement is not a position), and
ToInteger on a number (rounds). Where an interface declares the obligation,
the whole family shares one spelling: IMesh3D is explicit for everyone,
including the implementers whose own lift happens to be faithful.
Owners: the convention is stated only here. Conversion-bearing declarations
cite it — transforms.concepts.plato for the transform family,
fields-implicits.library.plato for the field and SDF lifts.
Matrices — row-vector multiplication (System.Numerics)
Plato multiplies a row vector on the left: v' = v M. RowN holds row N,
so element (r, c) is component c of row r, and each row is the image of a
basis vector. Composition reads left to right: in v (M1 * M2), M1 applies
first. Textbook column-vector M v is not the convention here; determinant
and "column images" discussions must be read against this row-major layout.
This is the same layout and multiplication model as System.Numerics.Matrix4x4
(and the other System.Numerics matrix types): C# interop and mental models should
treat Plato Matrix4x4 as Numerics-compatible, not as a column-major/OpenGL
M v textbook matrix.
Owners: matrices.types.plato (Matrix2x2..4x4, Matrix3x2, Matrix4x3, MatrixN);
the transform representations in transforms.types.plato and their bodies in
transforms.library.plato.
World space — Z-up, right-handed
World space is right-handed with +Z up (Up = (0, 0, 1)), +X right,
+Y forward/depth as used by the product. Ara 3D Studio is the authority
(CameraState.Up, axis gnomon, viewport labels). Plato geometry, cameras, and
importers must match Studio — do not invent a Y-up world convention here.
Owners: spatial / camera / transform libraries; Studio viewport and gizmo code.
Winding, handedness, and normals
Right-handed coordinate system throughout (world Z-up, above). A face/polygon
loop is counter-clockwise (CCW) as seen from its front / outside, so the
outward normal follows the right-hand rule over the CCW vertex order.
CounterClockwise is the WindingOrder default. Ara 3D Studio is the product
authority for this winding choice; Plato matches Studio. A mirroring transform
(negative determinant) inverts winding — the usual source of "suddenly inverted"
meshes after a mirror; re-orient on import rather than carrying a per-mesh flag.
Owners: topology.types.plato (WindingOrder), meshes.types.plato (face
normals), planar.types.plato (Triangle2D: CCW positive area) /
spatial-primitives.types.plato (Triangle3D: right-hand normal).
Typed indices — -1 means "none"
A cross-array reference is a typed index (implements Index, single
Value: Integer), never a raw Integer. A Value of -1 (any negative) is the
"none" / absent sentinel; a non-negative Value is a valid zero-based
position. For multi-references, an empty array is the corresponding "none".
CSR/offset arrays, counts, and bitmasks are plain Integer and are not
governed by this rule. (The CSR packing itself is the Jagged interface in
collections.concepts.plato, which states that invariant once.) Axis selectors are no longer plain Integer: a
cardinal-axis choice is the typed Axis3D / Axis2D / SignedAxis3D sum
(axes.types.plato), whose Ordinal recovers the Integer component index when one
is genuinely needed — kd-tree split axes, UpAxis / ForwardAxis fields, and
longest-extent queries take an axis type, not a bare 0/1/2.
Owners: Index interface (collections.concepts.plato); every typed
index type (topology.types.plato VertexIndex/UndirectedEdgeIndex/..., domain files);
ItemIndex (numbers.types.plato); axis selectors (axes.types.plato
Axis3D/Axis2D/SignedAxis3D).
Angles — Angle-typed, radians-canonical
An angle is always the Angle type, never a raw Number. Angle stores
radians (quantities.types.plato); radians is the storage unit, not the
interchange type. Build angles through the unit constructors
n.Degrees / n.Turns / n.Gradians / n.ArcMinutes / n.ArcSeconds
(angles.library.plato); the raw path is the radians cast Angle(x)
(angles.library.plato). Read them back with the matching accessors
(a.Degrees, a.Turns, ...).
Owners: Angle (quantities.types.plato), angles.library.plato.
Canonical angle interval
Normalize(a: Angle) reduces to the canonical half-open interval (-pi, pi]
— equivalently (-180 deg, 180 deg]. This matches the range of atan2: +pi
is included, -pi is not. Wrap(a, period) reduces to [0, period).
LerpShortest interpolates along the shortest signed arc (the normalized
delta), and EquivalentAngle compares canonical (normalized) forms.
Owner: angles.library.plato.
Bounds — inclusive, with an empty encoding
Axis-aligned bounds and intervals are inclusive on both Min and Max
(inclusive on Max unless a doc comment says otherwise). The empty region is
encoded by inversion: Start > End for a NumberInterval, or Min component-
wise greater than Max for Bounds. An empty region contains no points and is
the identity for Union — this is what makes "grow from empty" correct.
Owners: intervals.types.plato (NumberInterval, AngleInterval, LengthInterval,
IntegerInterval), intervals.types.plato (Bounds2D/3D, IntegerBounds2D/3D,
Rect2D); interface in intervals-bounds.concepts.plato (IInterval, IBounds).
Color — linear-light, straight alpha
Color is linear-light RGBA, components nominally in [0, 1] (wide-gamut /
HDR values may exceed 1). It is the canonical color type for computation;
interpolation and arithmetic are component-wise and assume unpremultiplied
(straight) alpha — Color.A is straight, not premultiplied. Do not
construct a Color from sRGB hex/bytes without decoding, and do not Lerp
Color8 or sRGB-encoded bytes without converting to linear first — both are
common and silent errors. Color8 is the 8-bit, typically sRGB-encoded interop
form.
Owners: color.types.plato (Color, Color8).
View space — camera-local, forward is -Z
View space is not world space. World remains Z-up (above). After a look-at /
view transform, camera-local coordinates are right-handed with the camera
looking down local -Z, local +Y up, and local +X right
(OpenGL-style view frame). A look-at basis takes
right = normalize(forward × up) in that right-handed order, where up is the
world up axis (+Z). Projection matrices and per-backend look-at lowerings must
honour this so handedness bugs are not rediscovered per backend.
Owner: cameras.concepts.plato (the Camera interface), cameras.types.plato
(LookAtCamera and the concrete camera types).
UV origin — top-left, V increases downward
The texture-coordinate origin (u, v) = (0, 0) is at the top-left; U
increases to the right and V increases downward, over the unit square
[0,1] x [0,1]. This matches the default image storage (images.types.plato:
row-major from the top-left pixel, ImageOrigin.TopLeft) and the UvChart
mapping (meshes.types.plato: Bounds2D with X = U, Y = V). The
OpenGL-style bottom-left origin is opt-in via ImageOrigin.BottomLeft.
Top-left is chosen because the existing images / mesh-attributes / texturing
files already imply it.
Owners: points.types.plato (UvCoordinate, UvwCoordinate); cited from
meshes.types.plato, images.types.plato, texturing.types.plato.
Floating-point comparison — one epsilon policy
Numerical near-equality uses a single record, ComparisonTolerance
(numbers.types.plato) with { Absolute: Number, Relative: Number }. The test is
|x - y| <= Absolute + Relative * max(|x|, |y|). AlmostEqual overloads accept
it (algebra.library.plato, beside the scalar-tolerance AlmostEqual;
the component-wise vector lift is in numeric-structures.library.plato).
Engineering Tolerance (uncertainty.types.plato) is explicitly NOT this type:
its Plus / Minus are asymmetric acceptance allowances about a Nominal
manufacturing value, not comparison epsilons — do not stuff floating-point
epsilons into it (and use UncertainNumber for 1-sigma uncertainties).
Owners: ComparisonTolerance (numbers.types.plato), AlmostEqual
(algebra.library.plato), Tolerance (uncertainty.types.plato).