← Natura·Docs/Modules/Source

creatures/export-dae.js

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

// export-dae.js -- export the natura elephant as a COLLADA .dae (geometry + normals +
// one material per colour) for upload into OpenSimulator (which ingests COLLADA).
// Adapts export-obj.js. Triangulates polygons; groups triangles by colour into materials.
//   node export-dae.js [african|asian] [N] [outFile] [pose]
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 + '.dae');   // default: the current directory
const poseArg = process.argv[5] || 'stand';
const PRESETS = {
  stand: { trunkCurl: 0.12, earFlare: 0.10 },
  walk0: { gait: 0.0, trunkCurl: 0.12, earFlare: 0.14, headDown: 0.04 },
};
const pose = PRESETS[poseArg] || (poseArg.charAt(0) === '{' ? JSON.parse(poseArg) : PRESETS.stand);

const rig = R.buildElephantRigged(C.prims, { species, detail: 0.9, N });
rig.setPose(pose);
const faces = rig.faces;

// ---- geometry helpers ----
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
const cross = (a, b) => ({ x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x });
function unit(v) { const l = Math.hypot(v.x, v.y, v.z) || 1; return { x: v.x / l, y: v.y / l, z: v.z / l }; }
const faceNormal = (v) => unit(cross(sub(v[1], v[0]), sub(v[2], v[0])));

// ---- group faces by colour -> materials; build flat per-corner POS/NRM arrays ----
// SL/OpenSim mesh allows AT MOST 8 materials (faces) per object. The elephant bakes per-face
// shading, so grouping by unique colour gives thousands (invalid). Collapse to its ~6 logical
// materials by nearest base colour -- shading in-world then comes from OpenSim lighting.
const PALETTE = [
  { name: 'skin',     rgb: [140, 135, 128] },
  { name: 'innerear', rgb: [150, 132, 128] },
  { name: 'ivory',    rgb: [232, 226, 208] },
  { name: 'eye',      rgb: [92, 63, 38] },
  { name: 'pupil',    rgb: [22, 17, 13] },
  { name: 'tuft',     rgb: [40, 37, 34] },
];
const nearest = (c) => {
  let bi = 0, bd = Infinity;
  for (let i = 0; i < PALETTE.length; i++) {
    const p = PALETTE[i].rgb, 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 groups = new Map();               // paletteIndex -> {rgb, faces:[]}
for (const f of faces) {
  const pi = nearest(f.c);
  let g = groups.get(pi);
  if (!g) { g = { rgb: PALETTE[pi].rgb, faces: [] }; groups.set(pi, g); }
  g.faces.push(f);
}

const POS = [], NRM = [], mats = [];
let ci = 0;
for (const [key, g] of groups) {
  const idxs = [];
  for (const f of g.faces) {
    const v = f.v, n = f.n, fn = n ? null : faceNormal(v);
    for (let t = 1; t < v.length - 1; t++) {           // triangle-fan a tri/quad polygon
      for (const c of [0, t, t + 1]) {
        const p = v[c]; POS.push(p.x, p.y, p.z);
        const nn = n ? n[c] : fn; NRM.push(nn.x, nn.y, nn.z);
        idxs.push(ci++);
      }
    }
  }
  mats.push({ key, rgb: g.rgb, idxs });
}
const nCorners = ci;

// ---- emit COLLADA 1.4.1 ----
const f5 = (x) => (Math.round(x * 1e5) / 1e5);
const now = '2026-09-23T00:00:00Z';
let 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.js</authoring_tool></contributor>' +
       '<created>' + now + '</created><modified>' + now + '</modified>' +
       '<unit name="meter" meter="1"/><up_axis>Y_UP</up_axis></asset>');

// effects + materials (one per colour)
x.push('<library_effects>');
mats.forEach((m, i) => {
  const r = (m.rgb[0] / 255), g = (m.rgb[1] / 255), b = (m.rgb[2] / 255);
  x.push('<effect id="eff' + i + '"><profile_COMMON><technique sid="t"><lambert>' +
    '<diffuse><color>' + f5(r) + ' ' + f5(g) + ' ' + f5(b) + ' 1</color></diffuse>' +
    '</lambert></technique></profile_COMMON></effect>');
});
x.push('</library_effects>');
x.push('<library_materials>');
mats.forEach((m, i) => x.push('<material id="mat' + i + '" name="mat' + i + '"><instance_effect url="#eff' + i + '"/></material>'));
x.push('</library_materials>');

// geometry
x.push('<library_geometries><geometry id="geo" name="great_grey"><mesh>');
x.push('<source id="pos"><float_array id="pos-a" count="' + POS.length + '">' + POS.map(f5).join(' ') + '</float_array>' +
  '<technique_common><accessor source="#pos-a" count="' + nCorners + '" 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="' + NRM.length + '">' + NRM.map(f5).join(' ') + '</float_array>' +
  '<technique_common><accessor source="#nrm-a" count="' + nCorners + '" 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"/></vertices>');
mats.forEach((m, i) => {
  const tcount = m.idxs.length / 3;
  const p = m.idxs.map((k) => k + ' ' + k).join(' ');   // VERTEX(offset0)=k, NORMAL(offset1)=k
  x.push('<triangles material="sym' + i + '" count="' + tcount + '">' +
    '<input semantic="VERTEX" source="#verts" offset="0"/>' +
    '<input semantic="NORMAL" source="#nrm" offset="1"/>' +
    '<p>' + p + '</p></triangles>');
});
x.push('</mesh></geometry></library_geometries>');

// visual scene
x.push('<library_visual_scenes><visual_scene id="scene" name="scene"><node id="GreatGrey" name="GreatGrey">');
x.push('<instance_geometry url="#geo"><bind_material><technique_common>');
mats.forEach((m, i) => x.push('<instance_material symbol="sym' + i + '" target="#mat' + i + '"/>'));
x.push('</technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes>');
x.push('<scene><instance_visual_scene url="#scene"/></scene>');
x.push('</COLLADA>');

fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, x.join('\n'));
console.log('wrote', outFile, '| corners', nCorners, 'triangles', nCorners / 3, 'materials', mats.length);