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.
| Kind | Class | Binary | XML |
|---|---|---|---|
| Skeleton | Skeleton | .csf | .xsf |
| Mesh | Mesh | .cmf | .xmf |
| Material | Material | .crf | .xrf |
| Animation | Animation | .caf | .xaf |
| Morph animation | MorphAnimation | .cpf | .xpf |
Inspect any file
This example identifies a file, prints a summary, and optionally converts it:
/**
* @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'})`);
}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
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:
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:
animation.toBinary({ compress: true }); // smaller, lossy
animation.toBinary(); // losslessGet render-ready buffers
submesh.toBuffers() produces typed arrays ready for WebGL or any engine:
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
wwhen it loads them; this library doesn't, matching upstream Cal3D. Negatewyourself if you compare against IMVU's runtime values. - Materials don't contain images. A
TextureMapnames a texture file (material.diffuseMap?.filename) that ships with the product or one of its parents. Fetch it withproduct.fetchAsset(name)from@imvu/chkn. - A header-only
.xmfis an empty mesh. Creators ship these to remove a mesh inherited from a parent product.