← Natura·Docs/Modules/Source

creatures/export-dae-rigged.js

168 lines · in natura-src-1.0.0.zip as natura/creatures/export-dae-rigged.js · a node script (exports a creature model to a file) · its entry in the module reference

// export-dae-rigged.js -- export the natura elephant as a RIGGED COLLADA .dae:
// a real custom anatomical skeleton (pelvis/spine/chest/neck/head, a 5-bone trunk chain,
// four legs each hip->knee->foot, two ears, a tail = 25 bones) + weighted linear-blend
// skinning derived from the rig's own per-vertex part/param classification (honest weights,
// not guessed). Emits COLLADA 1.4.1 with a <skin> controller for upload into OpenSimulator.
//   node export-dae-rigged.js [african|asian] [N] [outFile]
const C = require('./render3d.js');
const R = require('./rig.js');
const fs = require('fs');
const path = require('path');

const species = process.argv[2] || 'african';
const N = parseInt(process.argv[3] || '64', 10);
const outFile = process.argv[4] || ('great-grey-' + species + '-rigged.dae');   // default: the current directory

const rig = R.buildElephantRigged(C.prims, { species, detail: 0.9, N });
rig.setPose({ trunkCurl: 0.10, earFlare: 0.08 });          // a gentle rest pose = the bind pose
const faces = rig.faces;
const { uniq, REST, META, anatomy: A } = rig.rigData;
const nV = uniq.length;
const smooth = (t) => t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t);

// ---- per-vertex normals: first face-normal seen for each unique vertex ----
const idxOf = new Map();
uniq.forEach((o, i) => idxOf.set(o, i));
const NRM = new Array(nV).fill(null);
for (const f of faces) for (let k = 0; k < f.v.length; k++) {
  const i = idxOf.get(f.v[k]);
  if (i != null && !NRM[i]) NRM[i] = f.n ? f.n[k] : { x: 0, y: 1, z: 0 };
}
for (let i = 0; i < nV; i++) if (!NRM[i]) NRM[i] = { x: 0, y: 1, z: 0 };

// ---- skeleton (custom, anatomical; translation-only bind pose) ----
const B = [], bidx = {};
const bone = (name, x, y, z, parent) => { bidx[name] = B.length; B.push({ name, x, y, z, parent: parent == null ? -1 : bidx[parent] }); };
bone('pelvis', A.legs[2].x, A.hipY, 0, null);
bone('spine', 0.0, 1.55, 0, 'pelvis');
bone('chest', 0.70, 1.60, 0, 'spine');
bone('neck', A.neck.x, A.neck.y, 0, 'chest');
bone('head', A.hx, A.hy, 0, 'neck');
A.TP.forEach((p, i) => bone('trunk' + i, p.x, p.y, p.z, i === 0 ? 'head' : ('trunk' + (i - 1))));
bone('earL', A.hx - 0.10, A.hy, 0.44, 'head');
bone('earR', A.hx - 0.10, A.hy, -0.44, 'head');
const legParent = ['chest', 'chest', 'pelvis', 'pelvis'];
A.legs.forEach((lg, k) => {
  const kneeY = lg.y - 0.55 * (lg.y - A.footY0);
  bone('hip' + k, lg.x, lg.y, lg.z, legParent[k]);
  bone('knee' + k, lg.x, kneeY, lg.z, 'hip' + k);
  bone('foot' + k, lg.x, A.footY0, lg.z, 'knee' + k);
});
bone('tail', A.legs[2].x - 0.24, 1.30, 0, 'pelvis');
const nB = B.length;

// spine chain (by x) for weighting body verts
const SPINE = [['pelvis', A.legs[2].x], ['spine', 0.0], ['chest', 0.70], ['neck', A.neck.x]].sort((a, b) => a[1] - b[1]);

// ---- per-vertex skin weights (from the rig's own classification) ----
const VW = [];            // VW[i] = [{b:boneIndex, w}]
for (let i = 0; i < nV; i++) {
  const m = META[i], p = REST[i], w = {};
  const add = (name, val) => { if (val > 0) w[name] = (w[name] || 0) + val; };
  if (m.part === 'trunk') {
    const f = Math.max(0, Math.min(4, m.t * 4)), i0 = Math.min(3, Math.floor(f)), fr = f - i0;
    add('trunk' + i0, 1 - fr); add('trunk' + (i0 + 1), fr);
    if (m.t < 0.15) add('head', (0.15 - m.t) / 0.15 * 0.5);
  } else if (m.part === 'leg') {
    const k = m.leg, legW = smooth((m.t2 - 0.06) / 0.30), f = Math.max(0, Math.min(2, m.t2 * 2));
    if (f < 1) { add('hip' + k, (1 - f) * legW); add('knee' + k, f * legW); }
    else { add('knee' + k, (2 - f) * legW); add('foot' + k, (f - 1) * legW); }
    add(legParent[k], 1 - legW);
  } else if (m.part === 'head') {
    const wh = (m.wh == null ? 1 : m.wh); add('head', wh); add('neck', 1 - wh);
  } else if (m.part === 'ear') {
    add(m.side > 0 ? 'earL' : 'earR', 0.85); add('head', 0.15);
  } else if (m.part === 'tail') {
    add('tail', 0.8); add('pelvis', 0.2);
  } else {                                   // body: blend the two spine bones bracketing this x
    let x = Math.max(SPINE[0][1], Math.min(SPINE[SPINE.length - 1][1], p.x)), done = false;
    for (let s = 0; s < SPINE.length - 1; s++) {
      if (x <= SPINE[s + 1][1]) { const fr = (x - SPINE[s][1]) / (SPINE[s + 1][1] - SPINE[s][1] || 1); add(SPINE[s][0], 1 - fr); add(SPINE[s + 1][0], fr); done = true; break; }
    }
    if (!done) add('spine', 1);
  }
  // to bone indices, keep top 4, normalise to sum 1
  let inf = Object.keys(w).map((n) => ({ b: bidx[n], w: w[n] })).sort((a, b) => b.w - a.w).slice(0, 4);
  const sum = inf.reduce((s, e) => s + e.w, 0) || 1;
  inf.forEach((e) => e.w /= sum);
  VW.push(inf);
}

// ---- geometry: dedup positions/normals; triangles grouped into <=6 logical materials ----
const PALETTE = [ [140,135,128], [150,132,128], [232,226,208], [92,63,38], [22,17,13], [40,37,34] ];
const nearest = (c) => { let bi = 0, bd = Infinity; for (let i = 0; i < PALETTE.length; i++) { const p = PALETTE[i], d = (c[0]-p[0])**2 + (c[1]-p[1])**2 + (c[2]-p[2])**2; if (d < bd) { bd = d; bi = i; } } return bi; };
const matTris = PALETTE.map(() => []);          // matTris[m] = [ [i0,i1,i2], ... ] uniq indices
for (const f of faces) {
  const m = nearest(f.c), vi = f.v.map((o) => idxOf.get(o));
  for (let t = 1; t < vi.length - 1; t++) matTris[m].push([vi[0], vi[t], vi[t + 1]]);
}
const usedMats = PALETTE.map((_, m) => m).filter((m) => matTris[m].length);

// ---- matrices (row-major 4x4) ----
const f5 = (x) => (Math.round(x * 1e5) / 1e5);
const invBind = (b) => [1,0,0,-b.x, 0,1,0,-b.y, 0,0,1,-b.z, 0,0,0,1];                 // translation-only bind -> inverse
const localMat = (b) => { const px = b.parent < 0 ? 0 : B[b.parent].x, py = b.parent < 0 ? 0 : B[b.parent].y, pz = b.parent < 0 ? 0 : B[b.parent].z;
  return [1,0,0, b.x - px, 0,1,0, b.y - py, 0,0,1, b.z - pz, 0,0,0,1]; };

// ---- emit COLLADA ----
const X = [];
X.push('<?xml version="1.0" encoding="utf-8"?>');
X.push('<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">');
X.push('<asset><contributor><authoring_tool>natura export-dae-rigged.js</authoring_tool></contributor>' +
  '<created>2026-09-23T00:00:00Z</created><modified>2026-09-23T00:00:00Z</modified>' +
  '<unit name="meter" meter="1"/><up_axis>Y_UP</up_axis></asset>');
// effects + materials
X.push('<library_effects>');
usedMats.forEach((m) => { const c = PALETTE[m]; X.push('<effect id="eff' + m + '"><profile_COMMON><technique sid="t"><lambert>' +
  '<diffuse><color>' + f5(c[0]/255) + ' ' + f5(c[1]/255) + ' ' + f5(c[2]/255) + ' 1</color></diffuse></lambert></technique></profile_COMMON></effect>'); });
X.push('</library_effects><library_materials>');
usedMats.forEach((m) => X.push('<material id="mat' + m + '"><instance_effect url="#eff' + m + '"/></material>'));
X.push('</library_materials>');
// geometry
const posArr = [], nrmArr = [];
for (let i = 0; i < nV; i++) { const p = uniq[i], n = NRM[i]; posArr.push(f5(p.x), f5(p.y), f5(p.z)); nrmArr.push(f5(n.x), f5(n.y), f5(n.z)); }
X.push('<library_geometries><geometry id="geo" name="great_grey"><mesh>');
X.push('<source id="pos"><float_array id="pos-a" count="' + posArr.length + '">' + posArr.join(' ') + '</float_array>' +
  '<technique_common><accessor source="#pos-a" count="' + nV + '" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source>');
X.push('<source id="nrm"><float_array id="nrm-a" count="' + nrmArr.length + '">' + nrmArr.join(' ') + '</float_array>' +
  '<technique_common><accessor source="#nrm-a" count="' + nV + '" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source>');
X.push('<vertices id="verts"><input semantic="POSITION" source="#pos"/><input semantic="NORMAL" source="#nrm"/></vertices>');
usedMats.forEach((m) => { const tris = matTris[m]; X.push('<triangles material="sym' + m + '" count="' + tris.length + '">' +
  '<input semantic="VERTEX" source="#verts" offset="0"/><p>' + tris.map((t) => t.join(' ')).join(' ') + '</p></triangles>'); });
X.push('</mesh></geometry></library_geometries>');
// controller (skin)
const jointNames = B.map((b) => b.name).join(' ');
const invArr = []; B.forEach((b) => invBind(b).forEach((v) => invArr.push(f5(v))));
const weightVals = [], vcount = [], vlist = [];
VW.forEach((inf) => { vcount.push(inf.length); inf.forEach((e) => { vlist.push(e.b, weightVals.length); weightVals.push(f5(e.w)); }); });
X.push('<library_controllers><controller id="ctrl"><skin source="#geo">');
X.push('<bind_shape_matrix>1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</bind_shape_matrix>');
X.push('<source id="joints"><Name_array id="joints-a" count="' + nB + '">' + jointNames + '</Name_array>' +
  '<technique_common><accessor source="#joints-a" count="' + nB + '" stride="1"><param name="JOINT" type="name"/></accessor></technique_common></source>');
X.push('<source id="binds"><float_array id="binds-a" count="' + invArr.length + '">' + invArr.join(' ') + '</float_array>' +
  '<technique_common><accessor source="#binds-a" count="' + nB + '" stride="16"><param name="TRANSFORM" type="float4x4"/></accessor></technique_common></source>');
X.push('<source id="skinw"><float_array id="skinw-a" count="' + weightVals.length + '">' + weightVals.join(' ') + '</float_array>' +
  '<technique_common><accessor source="#skinw-a" count="' + weightVals.length + '" stride="1"><param name="WEIGHT" type="float"/></accessor></technique_common></source>');
X.push('<joints><input semantic="JOINT" source="#joints"/><input semantic="INV_BIND_MATRIX" source="#binds"/></joints>');
X.push('<vertex_weights count="' + nV + '"><input semantic="JOINT" source="#joints" offset="0"/><input semantic="WEIGHT" source="#skinw" offset="1"/>' +
  '<vcount>' + vcount.join(' ') + '</vcount><v>' + vlist.join(' ') + '</v></vertex_weights>');
X.push('</skin></controller></library_controllers>');
// visual scene: joint hierarchy + the skinned instance
function emitJoint(i) {
  const b = B[i], kids = B.map((c, ci) => ci).filter((ci) => B[ci].parent === i);
  let s = '<node id="' + b.name + '" sid="' + b.name + '" name="' + b.name + '" type="JOINT"><matrix sid="transform">' + localMat(b).map(f5).join(' ') + '</matrix>';
  for (const ci of kids) s += emitJoint(ci);
  return s + '</node>';
}
X.push('<library_visual_scenes><visual_scene id="scene" name="scene">');
X.push(emitJoint(0));   // pelvis root
X.push('<node id="GreatGrey" name="GreatGrey"><instance_controller url="#ctrl"><skeleton>#pelvis</skeleton><bind_material><technique_common>');
usedMats.forEach((m) => X.push('<instance_material symbol="sym' + m + '" target="#mat' + m + '"/>'));
X.push('</technique_common></bind_material></instance_controller></node>');
X.push('</visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>');

fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, X.join('\n'));
console.log('wrote', outFile);
console.log('  vertices', nV, 'bones', nB, 'materials', usedMats.length, 'triangles', matTris.reduce((s, t) => s + t.length, 0));
console.log('  influences', weightVals.length, 'avg/vert', (weightVals.length / nV).toFixed(2));