Skip to content

Read and edit Cal3D files ​

IMVU products are built from Cal3D files: skeletons, meshes, materials and animations. @imvu/cal3d reads each kind into a plain object you can inspect, change and save again, in either the binary or the XML flavor.

KindClassBinaryXML
SkeletonSkeleton.csf.xsf
MeshMesh.cmf.xmf
MaterialMaterial.crf.xrf
AnimationAnimation.caf.xaf
Morph animationMorphAnimation.cpf.xpf

Inspect any file ​

This example identifies a file, prints a summary, and optionally converts it:

ts
/**
 * @imvu/cal3d — summarize any Cal3D file, and optionally convert it between
 * the binary and XML flavors. No login, no network.
 *
 *   node examples/cal3d-inspect.ts <file> [out]
 *
 * The file's kind and flavor come from its contents, never its extension
 * (IMVU's CDN serves binary files under XML extensions). When `out` ends in
 * `.x?f` the file is written as XML, otherwise as binary.
 */
import { readFile, writeFile } from 'node:fs/promises';

import { load } from '@imvu/cal3d';

const [input, output] = process.argv.slice(2);

if (!input) {
  console.error('Usage: cal3d-inspect.ts <file> [out]');
  process.exit(1);
}

const file = load(await readFile(input));

switch (file.kind) {
  case 'skeleton':
    console.log(`Skeleton: ${file.value.bones.length} bones`);
    for (const { bone, depth } of file.value.walk()) console.log(`${'  '.repeat(depth + 1)}${bone.name}`);
    break;
  case 'mesh':
    console.log(`Mesh: ${file.value.vertexCount} vertices, ${file.value.faceCount} faces`);
    for (const [index, submesh] of file.value.submeshes.entries()) {
      console.log(`  submesh ${index}: material ${submesh.materialId}, ${submesh.vertices.length} vertices`);
    }
    if (file.value.morphNames.length) console.log(`  morphs: ${file.value.morphNames.join(', ')}`);
    break;
  case 'material':
    console.log('Material');
    for (const map of file.value.maps) console.log(`  ${map.type}: ${map.filename}`);
    break;
  case 'animation':
    console.log(`Animation: ${file.value.duration}s, ${file.value.tracks.length} bone tracks`);
    break;
  case 'morphAnimation':
    console.log(`Morph animation: ${file.value.duration}s`);
    for (const track of file.value.tracks) console.log(`  ${track.morphName}: ${track.keyframes.length} keyframes`);
    break;
}

if (output) {
  const asXml = /\.x[a-z]f$/i.test(output);
  await writeFile(output, asXml ? file.value.toXml() : file.value.toBinary());
  console.log(`Wrote ${output} (${asXml ? 'XML' : 'binary'})`);
}
sh
node examples/cal3d-inspect.ts Room.MESH.xmf
# Mesh: 937 vertices, 814 faces
#   submesh 0: material 0, 937 vertices

node examples/cal3d-inspect.ts RootNode_skeleton.Room.xsf
# Skeleton: 47 bones
#   skeleton.Room
#     Room.MESH
#     Seat01.Standing
#     …

Don't trust the extension

IMVU's CDN serves binary files under XML extensions: a .xmf is usually a binary mesh. load() and every X.parse() decide from the contents, so pass bytes and let them work it out.

When you know the kind ​

ts
import { Mesh, Skeleton } from '@imvu/cal3d';

const skeleton = Skeleton.parse(bytes);
skeleton.getBone('Head'); // by name or id
skeleton.roots;
for (const { bone, depth } of skeleton.walk()) console.log('  '.repeat(depth) + bone.name);

const mesh = Mesh.parse(bytes);
for (const submesh of mesh.submeshes) {
  submesh.materialId; // which material slot it uses
  submesh.vertices[0].influences; // [{ boneId, weight }, …]
  submesh.morphTargets.map((morph) => morph.name);
}

Edit and save ​

Objects are mutable. Change them, then write either flavor:

ts
const skeleton = Skeleton.parse(bytes);
skeleton.getBone('Head')!.translation.z += 1;

const binary: Uint8Array = skeleton.toBinary();
const xml: string = skeleton.toXml();

Everything IMVU uses survives a round trip: Mesh.parse(mesh.toBinary()) and Mesh.parse(mesh.toXml()) both equal the original. The exceptions are Cal3D features IMVU never used (progressive-mesh LOD, cloth springs), which are dropped on read, as IMVU's own loader does.

Animations can be written compressed, as IMVU's newer files are:

ts
animation.toBinary({ compress: true }); // smaller, lossy
animation.toBinary(); // lossless

Get render-ready buffers ​

submesh.toBuffers() produces typed arrays ready for WebGL or any engine:

ts
const { positions, normals, texCoords, indices, skinIndices, skinWeights } = submesh.toBuffers();

See Render with three.js for a full example.

Things to know ​

  • Quaternions are stored as in the file. IMVU's runtime negates w when it loads them; this library doesn't, matching upstream Cal3D. Negate w yourself if you compare against IMVU's runtime values.
  • Materials don't contain images. A TextureMap names a texture file (material.diffuseMap?.filename) that ships with the product or one of its parents. Fetch it with product.fetchAsset(name) from @imvu/chkn.
  • A header-only .xmf is an empty mesh. Creators ship these to remove a mesh inherited from a parent product.

Released under the MIT License.