Zum Inhalt

embed/js/viewer.js

// Core runtime for the embed viewer: engine bootstrap, splat loading,
// percentile auto-framing, auto-orbit. Imported lazily by main.js so the
// engine (vendor/playcanvas.mjs) is only fetched after the gate is clicked.
import {
  Application, Asset, AssetListLoader, Entity, Vec3,
  FILLMODE_FILL_WINDOW, RESOLUTION_AUTO
} from 'playcanvas';
import { frameBounds } from './autoframe.js';

const ORBIT_SPEED = 8; // degrees per second

// Generic pose for arbitrary splats — z:180 is the common Brush/COLMAP flip.
// The camera is re-framed from the splat centers right after loading.
const DEFAULT_POSE = {
  level: { x: 0, y: 0, z: 180 },
  cameraPos: { x: 0, y: 0, z: 4 },
  cameraFocus: { x: 0, y: 0, z: 0 }
};

export async function createViewer(hostElement) {
  const canvas = document.createElement('canvas');
  hostElement.appendChild(canvas);

  const app = new Application(canvas, {
    graphicsDeviceOptions: { antialias: false }
  });
  app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
  app.setCanvasResolution(RESOLUTION_AUTO);
  app.start();
  window.addEventListener('resize', () => app.resizeCanvas());

  const camera = new Entity('camera');
  camera.addComponent('camera', { clearColor: [0.055, 0.059, 0.075, 1] });
  camera.setPosition(
    DEFAULT_POSE.cameraPos.x, DEFAULT_POSE.cameraPos.y, DEFAULT_POSE.cameraPos.z);
  app.root.addChild(camera);

  // camera-controls is a PlayCanvas ESM script; loading it through the asset
  // registry lets the engine attach it. Its `import 'playcanvas'` resolves to
  // the vendored engine via the importmap in index.html.
  const ccAsset = new Asset('camera-controls', 'script', {
    url: 'vendor/camera-controls.mjs'
  });
  await new Promise((res) => new AssetListLoader([ccAsset], app.assets).load(res));
  camera.addComponent('script');
  const cc = camera.script.create('cameraControls');

  // splatPivot rotates for the auto-orbit; splatEntity holds the static leveling
  let splatPivot = null;
  let splatEntity = null;
  let autoOrbit = true;
  const orbitListeners = [];

  function setAutoOrbit(on) {
    on = !!on;
    if (on === autoOrbit) return;
    autoOrbit = on;
    for (const fn of orbitListeners) {
      try { fn(on); } catch (e) { console.error(e); }
    }
  }

  app.on('update', (dt) => {
    if (autoOrbit && splatPivot) splatPivot.rotate(0, ORBIT_SPEED * dt, 0);
  });

  // Any manual interaction takes over from the auto-orbit.
  for (const ev of ['pointerdown', 'wheel']) {
    canvas.addEventListener(ev, () => setAutoOrbit(false));
  }

  // opts.pose: optional [cx,cy,cz,fx,fy,fz,yaw] from the pose-authoring
  // workflow (?pose=…) — when present, skips auto-frame, applies yaw (degrees)
  // to the splat pivot, and positions the camera directly. opts.orbit: whether
  // auto-orbit should start after loading (default true, matching historical
  // behavior when omitted).
  async function loadSplat(url, filename, opts = {}) {
    // loadFromUrlAndFilename: the filename drives format detection (.sog/.ply).
    let asset;
    try {
      asset = await new Promise((resolve, reject) => {
        app.assets.loadFromUrlAndFilename(url, filename, 'gsplat', (err, a) => {
          if (err) reject(err); else resolve(a);
        });
      });
    } catch {
      throw new Error('splat-load-failed');
    }

    if (splatPivot) splatPivot.destroy();
    splatPivot = new Entity('splat-pivot');
    splatEntity = new Entity('splat');
    splatEntity.addComponent('gsplat', { asset });
    splatEntity.setLocalEulerAngles(
      DEFAULT_POSE.level.x, DEFAULT_POSE.level.y, DEFAULT_POSE.level.z);
    splatPivot.addChild(splatEntity);
    app.root.addChild(splatPivot);

    if (opts.pose) {
      const [cx, cy, cz, fx, fy, fz, yaw] = opts.pose;
      splatPivot.setLocalEulerAngles(0, yaw, 0);
      cc.reset(new Vec3(fx, fy, fz), new Vec3(cx, cy, cz));
    } else {
      cc.reset(
        new Vec3(DEFAULT_POSE.cameraFocus.x, DEFAULT_POSE.cameraFocus.y,
                 DEFAULT_POSE.cameraFocus.z),
        new Vec3(DEFAULT_POSE.cameraPos.x, DEFAULT_POSE.cameraPos.y,
                 DEFAULT_POSE.cameraPos.z));
      await autoFrame();
    }
    setAutoOrbit(opts.orbit !== false);
  }

  // Frame the camera from the splat center positions (PC 2.x:
  // gsplat._instance.resource.centers — note the underscore) using per-axis
  // percentiles so floater outliers don't blow up the framing. Falls back
  // silently to DEFAULT_POSE if positions are unavailable.
  async function autoFrame() {
    const getCenters = () => {
      const g = splatEntity && splatEntity.gsplat;
      return (g && ((g._instance && g._instance.resource && g._instance.resource.centers) ||
                    (g.instance && g.instance.resource && g.instance.resource.centers))) || null;
    };
    let centers = null;
    for (let i = 0; i < 60 && !centers; i++) {
      centers = getCenters();
      if (!centers) await new Promise((r) => requestAnimationFrame(r));
    }
    if (!centers) return;

    const bounds = frameBounds(centers);
    if (!bounds) return;

    let center = new Vec3(bounds.center[0], bounds.center[1], bounds.center[2]);
    center = splatEntity.getWorldTransform().transformPoint(center.clone());
    const dist = bounds.radius * 2.4;
    const dir = new Vec3(0.8, 0.5, 0.8).normalize();
    const camPos = new Vec3(
      center.x + dir.x * dist,
      center.y + dir.y * dist,
      center.z + dir.z * dist);
    cc.reset(center, camPos);
  }

  // Current camera pose for the authoring "copy pose" workflow — reflects
  // manual orbiting/zooming AND the auto-orbit pivot's accumulated rotation,
  // not just the pose at load time. cc.focusPoint returns a reused internal
  // Vec3, so its components are copied out immediately.
  function getPose() {
    const camPos = camera.getPosition();
    const focus = cc.focusPoint;
    const yaw = splatPivot ? splatPivot.getLocalEulerAngles().y : 0;
    return {
      camPos: [camPos.x, camPos.y, camPos.z],
      focus: [focus.x, focus.y, focus.z],
      yaw,
    };
  }

  return {
    loadSplat,
    setAutoOrbit,
    isAutoOrbit() { return autoOrbit; },
    onAutoOrbitChange(fn) { orbitListeners.push(fn); },
    getPose,
  };
}