Geometry

Raw mesh data — vertex, normal, index and UV buffers — plus the primitive builders behind Mesh.box() and friends. It is not a node: it is drawn through Mesh.from() (or many times by an InstancedMesh).

  • move a primitive's pivot to its base before making a mesh
  • build a procedural mesh from your own vertices and triangles
  • load a model's triangles from a GLB as one geometry
  • tile a texture across a stretched surface
  • colour a mesh per vertex
  • draw lines or points instead of triangles

Platforms: every host; vertex colours (getColor() in a material) on iOS, Android, desktop, the web player and the le.codes viewer app — not on the web (Web Lite), and headless tests ignore them.

At a glance

TypeScript
const scene = new Scene()
scene.add(Light.sun())

// a box whose pivot is at its BOTTOM face (so y = 0 puts it on the floor)
const geo = Geometry.box().scale(1, 2, 1).translate(0, 1, 0)
const pillar = Mesh.from(geo, { material: Material.lit({ color: '#ccc' }) })
scene.add(pillar)
scene.open()

Primitive shapes: Geometry.box, .sphere, .cylinder, .capsule, .plane

TypeScript
Geometry.box(size?: Vec3Like | number): Geometry   // full edge lengths; default 1 (unit cube)
Geometry.sphere(options?: {
  radius?: number          // default 0.5
  widthSegments?: number   // default 32
  heightSegments?: number  // default 32
}): Geometry
Geometry.cylinder(options?: {
  radius?: number          // default 0.5 (both caps)
  radiusTop?: number       // default = radius (0 makes a cone)
  radiusBottom?: number    // default = radius
  edges?: number           // default 32
  smooth?: boolean         // default true — smooth side normals; false = faceted
}): Geometry
Geometry.capsule(options?: {
  radius?: number          // default 0.5
  length?: number          // between the two cap centres; default 1 (0 = a sphere)
  widthSegments?: number   // around the axis; default 32
  capSegments?: number     // rings per hemisphere; default 8
}): Geometry
Geometry.plane(options?: {
  normal?: Vec3Like        // facing direction; default [0, 0, 1]
}): Geometry

All primitives are centred on the origin with normals and UVs filled in, unit-sized unless told otherwise (1 world unit ≈ 1 m across; the cylinder is 1 tall, the capsule runs along Y). The plane is a 1×1 quad perpendicular to normal. The box and the cylinder also fill uv1 (below).

Build a mesh from your own vertices: new Geometry

TypeScript
geo.vertices   // Float32Array — x,y,z triplets
geo.normals    // Float32Array — x,y,z triplets (per vertex)
geo.indices    // Uint16Array — triangle (or edge/point) indices
geo.uv         // Float32Array — u,v pairs (per vertex)
geo.uv1        // Float32Array | undefined — the lightmap UV set (non-overlapping; absent = uv is reused)
geo.colors     // Uint8Array | undefined — r,g,b,a per vertex (below)

new Geometry(vertices, normals, indices, uv)

Build a procedural mesh by filling the buffers yourself and constructing directly; a triangle whose indices run counter-clockwise as seen from its normal's side is the front face:

TypeScript
const scene = new Scene()
const tri = new Geometry(
  new Float32Array([ -1, 0, 0,  1, 0, 0,  0, 1.5, 0 ]),  // x,y,z per vertex
  new Float32Array([ 0, 0, 1,  0, 0, 1,  0, 0, 1 ]),     // normals, facing +Z
  new Uint16Array([ 0, 1, 2 ]),                           // counter-clockwise seen from +Z = front
  new Float32Array([ 0, 0,  1, 0,  0.5, 1 ]),             // u,v per vertex
)
scene.add(Light.sun(), Mesh.from(tri, { material: Material.lit({ color: '#4a8' }) }))
scene.open()

uv1 is what the Lightmap atlas samples a Mesh through.

Note

indices are Uint16Array, so a single geometry tops out at 65 535 vertices.

Load a model's triangles from a GLB: Geometry.load

TypeScript
Geometry.load(source: string | GeometryData): Promise<Geometry>
Geometry.fromData(data: GeometryData): Geometry

lecodes assets geometry casing.glb writes casing.geometry.json: the GLB's triangles as ONE buffer, world transforms baked in (--origin bottom, --scale to re-origin / rescale). Load it with Geometry.load(asset('./casing.geometry.json')) — a .json project file is inlined by the compiler, so that call reads the data directly (no fetch); a URL string is fetched. It is the mesh for mesh particles (shell casings, rubble, coins) and for Mesh.from. Materials, skins and clips are dropped; a file over 65 535 vertices is refused.

TypeScript
const main = async () => {
  const casing = await Geometry.load(asset('./casing.geometry.json'))
  const scene = new Scene()
  scene.add(Light.sun(), Mesh.from(casing, { material: Material.lit({ color: '#c90', metallic: 1 }) }))
  scene.open()
}
main()

Colour each vertex: geo.colors

TypeScript
const g = Geometry.box()
g.colors = new Uint8Array(g.vertices.length / 3 * 4)   // r, g, b, a per vertex, 0..255

A material that declares requires: [color] reads them with getColor() — one mesh in many colours instead of a material per shade, and a gradient can run along a strip (an editor's route coloured by speed, a debug drawer). The values reach the shader as 0..1 exactly as packed, so a shader fed sRGB hex should linearise them (pow(c, vec3(2.2))); baseColor is linear. Absent = every vertex white, which is what the built-in materials expect. Optional on the host: one built before this draws white. The lite engine carries them into its geometry, but its custom materials have no getColor() yet.

Move the pivot, resize, tile the texture: translate, scale, scaleUV

Move a shape's pivot (a box's to its bottom face), resize it or tile its texture by changing the vertex data itself — Geometry.box().translate(0, 0.5, 0) puts the pivot at the base:

TypeScript
geo.translate(x, y, z): this     // shift every vertex — moves the pivot
geo.scale(x, y, z): this         // scale every vertex
geo.scaleUV(x, y): this          // tile/stretch the texture coordinates

These mutate the buffers in place (chainable). Baking a transform into the geometry is different from node.scale: physics auto-shapes measure the baked size, a node's scale also scales its children while baked data does not, and normals are not recomputed — a non-uniform scale skews lighting slightly.

Draw lines or points: geo.kind

TypeScript
geo.kind = 'triangles' | 'edges' | 'vertices'    // default 'triangles' (write-only)

'edges' renders the index pairs as lines, 'vertices' as points — for wireframe/debug looks. The index buffer is read as it is: each PAIR of indices is one line, so a primitive's triangle list does not turn into its wireframe — give the lines their own indices:

TypeScript
const scene = new Scene()
const v = new Float32Array([ 0, 0, 0,  1, 0, 0,   0, 0, 0,  0, 1, 0,   0, 0, 0,  0, 0, 1 ])
const axes = new Geometry(v, new Float32Array(v.length), new Uint16Array([ 0, 1,  2, 3,  4, 5 ]), new Float32Array(12))
axes.kind = 'edges'                                    // each index PAIR is one line
scene.add(Mesh.from(axes, { material: Material.unlit({ color: '#ff0' }) }))
scene.open()

Pitfalls

TypeScript
// ✗ editing a Geometry after a Mesh was built from it
mesh.geometry.translate(0, 1, 0)     // buffers already uploaded — the mesh doesn't change
// ✓ finish the geometry first, then Mesh.from(geo) — or re-upload it
mesh.setGeometry(mesh.geometry.translate(0, 1, 0))

See also

  • Mesh — Mesh.from(geometry), setGeometry and the primitive factory shortcuts
  • Material — what the surface looks like
  • 3D physics & shapes — auto-shapes read the node's geometry
  • Particles — mesh particles from a geometry file