Skip to content
Shapemetry

@huukhanhnguyen/types / objects/mesh

objects/mesh

EdgeMark

EdgeMark = { attributes?: Record<string, unknown> | null; sharp?: boolean; v1: number; v2: number; }

One marked mesh edge — vertex-index pair plus its own claims. Named EdgeMark, not BrepEdge (BrepData's shared TOPOLOGY edge, other entries reference it by index; this is a discrete vertex-index pair with no sharing — the mesh analogue of CurveData's CurveEdge) and not MeshEdge (already taken: geometry/triangleMesh.ts's MeshEdge is the DISPLAY tessellation's rendered edge polyline, a different tier).

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this edge — absent or null = no claims. Written by object assembly or edge* ops, never hand-authored.

sharp?

optional sharp?: boolean

Hard edge (split normal) — Blender sharp_edge, the FBX smoothing-group boundary. Absent === smooth.

v1

v1: number

Vertex index into vertices.

v2

v2: number

Vertex index into vertices.


MeshData

MeshData = { edges?: EdgeMark[]; faces: MeshFace[]; morphs?: MorphTarget[]; skin?: SkinBinding; vertices: MeshVertex[]; weightsTrack?: string; }

Opaque n-gon polygon mesh — design topology stored as faces, triangles only at the door (render tessellation). Per-vertex claims live on each MeshVertex (NOTE the difference from shell tables: here vertex i's uv is vertices[i].uv, its normal vertices[i].normal, its skin influences vertices[i].skin). The object's kind is explicit in the evaluated value (MeshObject.type); no reader sniffs a geometry field to recognise it. Closed BY TYPE (the boundary law): only the mesh→mesh op family consumes it; boolean/BOM/tableSource/nesting/watertight filter it out by type.

Kept DISTINCT from the tessellation TriangleMesh wire type (see triangleMesh.ts) on purpose: the stream shape carries per-vertex MeshVertex.skin (indices/weights) while the tessellation expands it to skinIndices/skinWeights arrays (+ skeleton), and the tessellation adds the groups bake-time field a stream document never has. Tessellation always carries triangle indices.

edges?

optional edges?: EdgeMark[]

Sparse marked edges for interaction/shading — one entry per marked edge, array-of-structs — the same shape CurveData.edges keeps: an array of CurveEdge entries instead of a parallel curveAttributes. Runtime builds the FULL edge table from faces; this array only ever lists edges that carry a mark.

faces

faces: MeshFace[]

N-gon faces, array-of-structs — see MeshFace.

morphs?

optional morphs?: MorphTarget[]

Morph targets — see MorphTarget (offsets are per-VERTEX position deltas, flat and parallel to vertices by index — already the tessellation layout, no remap at render).

skin?

optional skin?: SkinBinding

This mesh's skeleton binding — see SkinBinding. Absent === not skinned. The per-vertex influences live on MeshVertex.skin; this holds only what is true of the whole document (which skeleton, and the bind pose it was attached at), which is why a writer reads the name and the inverse-bind matrices here and the weights per vertex.

vertices

vertices: MeshVertex[]

Vertices — see MeshVertex.

weightsTrack?

optional weightsTrack?: string

Track.key reference (2026-08-14) — this mesh's OWN weights channel. One track covers every entry in morphs at once: its values[i].length must equal morphs.length, in the SAME order — glTF packs one node's whole weights array into a single channel (confirmed against the spec: node.weights count MUST match the referenced mesh's morph target count), so one track already covers it — no per-target track to re-merge at export. Consumer-holds-the- reference, same as BoneEntity's *Track fields. Absent = this mesh's morph weights never animate (still usable as a static pose via each target's own authored weight elsewhere, out of scope here).


MeshFace

MeshFace = { attributes?: Record<string, unknown> | null; indices: number[]; layer?: string; material?: string; sharp?: boolean; }

One n-gon face — vertex-index loop plus its own claims. Array-of-structs: faces, faceAttributes and faceSharp used to be three arrays a caller had to keep index-locked by hand — the same burden edges/edgeAttributes carried before folding into EdgeMark, for the identical reason: three parallel arrays cost an index-sync bug surface and bought nothing.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this face — absent or null = no claims. Written by object assembly (merge) or face* ops, never hand-authored.

indices

indices: number[]

CCW outer loop of vertex indices into vertices, length ≥ 3. Non-planar faces allowed. A triangle is a 3-gon.

layer?

optional layer?: string

Layer key for THIS face, overriding its placement's Instance.layer. Absent === inherit from the instance.

Per-face for the reason material already is: an IMPORTED document can carry N different layers across its faces before it is ever wrapped into one Object — DXF especially, which assigns layer per ENTITY and never per block — and collapsing that to one Instance-level layer on arrival loses what the file actually said.

material?

optional material?: string

Material key for THIS face — a materials[] record name — overriding its placement's Instance.material. Absent === inherit from the instance.

The oldest per-face lane reference and the reason layer is one too: a glTF primitive, an OBJ usemtl run or a boolean fragment names a material per FACE, and one document holds many faces, so the claim cannot ride the placement.

sharp?

optional sharp?: boolean

Flat/hard shading — absent or false means smooth shaded. Blender sharp_face.


MeshInstance

MeshInstance = BaseInstance & { material?: string; }


MeshObject

MeshObject = BaseObject & { data: MeshData; instances: MeshInstance[]; type: "mesh"; }

An object whose data is one mesh buffer.

One object IS one buffer. Disconnected pieces ("islands") stay derived. Turning those islands into separately named objects is looseToBlocks.


MeshVertex

MeshVertex = { attributes?: Record<string, unknown> | null; normal?: [number, number, number]; position: [number, number, number]; skin?: { indices: [number, number, number, number]; weights: [number, number, number, number]; }; uv?: [number, number]; uv2?: [number, number]; }

One mesh vertex — position plus its own per-vertex claims. Array-of- structs replacing MeshData's old positions/uvs/uvs2/normals/skin(top)/ vertexAttributes parallel arrays, for the same reason faces/edges were already folded: N parallel arrays cost an index-sync bug surface and buy nothing.

attributes?

optional attributes?: Record<string, unknown> | null

Opaque claims for this vertex — absent or null = no claims.

normal?

optional normal?: [number, number, number]

Absent = a renderer derives it (area-weighted vertex normal).

position

position: [number, number, number]

skin?

optional skin?: { indices: [number, number, number, number]; weights: [number, number, number, number]; }

Skeletal binding of THIS vertex — 4 entries, glTF SKIN semantics. The other half is MeshData.skin, which names the skeleton and the bind pose once for the whole document.

indices

indices: [number, number, number, number]

weights

weights: [number, number, number, number]

uv?

optional uv?: [number, number]

glTF TEXCOORD_0 equivalent.

uv2?

optional uv2?: [number, number]

glTF TEXCOORD_1 equivalent — AO/lightmaps.


MorphTarget

MorphTarget = { name?: string; offsets: number[]; }

One morph target: per-vertex POSITION DELTAS, flat [dx,dy,dz] per vertex, parallel to MeshData.vertices by index (deliberate: a morph is a shape DELTA the engine reads directly, not a per-vertex claim). Static shape data — never animated itself; MeshData.weightsTrack is what varies over time.

name?

optional name?: string

offsets

offsets: number[]


SkinBinding

SkinBinding = { inverseBind: number[]; skeleton: string; }

A mesh's DOCUMENT-level skin facts (skeletal deformation) — glTF SKIN semantics. skeleton names the SkeletonObject's object key in the same model or component scope. Lookup of that key must resolve to a SkeletonObject or the engine throws. inverseBind is world_rest(B)⁻¹ at the moment THIS mesh was attached — a fact about the bind, not about a bone. Flat 16 × bones.length, column-major. Written by faceSkeleton — never hand-authored.

The per-POINT half (4 influences each) is MeshVertex.skin, not here: one document names one skeleton and one bind, while indices and weights vary per vertex. Holding both halves in one type would restate the per-vertex arrays the vertices already own.

inverseBind

inverseBind: number[]

skeleton

skeleton: string

Last updated: 📖 8 min readEdit on GitHub