Zum Inhalt

viewer/js/collision/persist.js

// persist.js — pure I/O helpers for the collision mesh: OBJ writer, RLE
// pack/unpack of the density grid, and the JSON sidecar codec.

const HEADER = '# autosplat-viewer collision mesh export\n';
const DENSITY_MAX = 10; // values above this clamp during 8-bit quantisation

export function writeObj({ positions, normals, indices }) {
  let out = HEADER;
  for (let i = 0; i < positions.length; i += 3) {
    out += `v ${fmt(positions[i])} ${fmt(positions[i + 1])} ${fmt(positions[i + 2])}\n`;
  }
  for (let i = 0; i < normals.length; i += 3) {
    out += `vn ${fmt(normals[i])} ${fmt(normals[i + 1])} ${fmt(normals[i + 2])}\n`;
  }
  for (let t = 0; t < indices.length; t += 3) {
    const a = indices[t] + 1, b = indices[t + 1] + 1, c = indices[t + 2] + 1;
    out += `f ${a}//${a} ${b}//${b} ${c}//${c}\n`;
  }
  return out;
}

function fmt(v) {
  // Six decimals is overkill for collision meshes; strip trailing zeros so
  // .obj files compress better and diff cleanly.
  const s = v.toFixed(6);
  return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
}

export function rlePack(density) {
  const out = [];
  let i = 0;
  while (i < density.length) {
    const q = quantise(density[i]);
    let n = 1;
    while (i + n < density.length && quantise(density[i + n]) === q) n++;
    out.push(n, q);
    i += n;
  }
  return out;
}

export function rleUnpack(packed, length) {
  const out = new Float32Array(length);
  let idx = 0;
  for (let p = 0; p < packed.length; p += 2) {
    const n = packed[p];
    const v = dequantise(packed[p + 1]);
    for (let k = 0; k < n; k++) out[idx++] = v;
  }
  return out;
}

function quantise(v) {
  const clamped = Math.max(0, Math.min(DENSITY_MAX, v));
  return Math.round((clamped / DENSITY_MAX) * 255);
}

function dequantise(q) {
  return (q / 255) * DENSITY_MAX;
}

export function encodeSidecar({ resolution, bounds, iso, density }) {
  return JSON.stringify({
    version: 1,
    resolution,
    bounds: {
      min: [bounds.min.x, bounds.min.y, bounds.min.z],
      max: [bounds.max.x, bounds.max.y, bounds.max.z],
    },
    iso,
    densityRLE: rlePack(density),
  });
}

export function decodeSidecar(json) {
  const obj = typeof json === 'string' ? JSON.parse(json) : json;
  if (obj.version !== 1) throw new Error(`unsupported sidecar version: ${obj.version}`);
  const length = obj.resolution ** 3;
  return {
    resolution: obj.resolution,
    bounds: {
      min: { x: obj.bounds.min[0], y: obj.bounds.min[1], z: obj.bounds.min[2] },
      max: { x: obj.bounds.max[0], y: obj.bounds.max[1], z: obj.bounds.max[2] },
    },
    iso: obj.iso,
    density: rleUnpack(obj.densityRLE, length),
  };
}