// collision-mode.js — PlayCanvas integration for the collision-mesh editor.
// Lazy-loaded by viewer.js. Owns the mesh entity, drives the voxelize → MC
// pipeline, exposes the editor + persist surfaces to the UI layer.
import {
Entity, Mesh, MeshInstance, StandardMaterial, BLEND_NORMAL, Color,
PRIMITIVE_TRIANGLES, CULLFACE_NONE,
} from 'playcanvas';
import { voxelize, smoothDensity, defaultIso } from './voxelize.js';
import { marchingCubes } from './marching-cubes.js';
import { buildBvh, raycast } from './mesh-bvh.js';
import { CollisionEditor } from './editor.js';
import { writeObj, encodeSidecar, decodeSidecar } from './persist.js';
const RESOLUTION = 64;
export class CollisionMode {
constructor({ app, camera, splatEntity, splatPivot, getSplatPositions }) {
this.app = app;
this.camera = camera;
this.splatEntity = splatEntity;
this.splatPivot = splatPivot;
this.getSplatPositions = getSplatPositions; // () => { positions, bounds }
this.editor = null;
this.bvh = null;
this.meshEntity = null;
this.lastMesh = null;
this._lockReleasedForBrush = false;
this._listeners = { built: [] };
}
/**
* Build (or rebuild) the mesh from the loaded splat. Returns true on
* success, false if no splat positions are available.
*/
build() {
const sp = this.getSplatPositions();
if (!sp || !sp.positions || sp.positions.length === 0) return false;
const { density } = voxelize(sp.positions, sp.bounds, RESOLUTION);
const smoothed = smoothDensity(density, RESOLUTION);
const iso = defaultIso(smoothed);
this.editor = new CollisionEditor({
density: smoothed,
resolution: RESOLUTION,
bounds: sp.bounds,
iso,
});
this.rebuildMesh();
return true;
}
/**
* Load a sidecar — replaces the editor state entirely.
*/
loadSidecar(json) {
const { resolution, bounds, iso, density } = decodeSidecar(json);
this.editor = new CollisionEditor({ density, resolution, bounds, iso });
this.rebuildMesh();
}
/**
* Apply one brush sample at a screen-space pointer position. If `continuing`
* is true, the caller is mid-stroke (drag-brush) — beginStroke/endStroke
* and the mesh-rebuild are the caller's responsibility, so the call is
* cheap enough to run on every pointermove. If `continuing` is false (or
* omitted), this is a one-shot stroke: begin + apply + end + rebuild.
*/
applyBrushAt(screenX, screenY, kind, radius, strength, continuing = false) {
if (!this.editor) return;
const hit = this._raycastFromScreen(screenX, screenY);
if (!hit) return;
if (!continuing) {
this.editor.beginStroke(kind);
this.editor.applyAt(hit.point, radius, strength);
this.editor.endStroke();
this.rebuildMesh();
} else {
this.editor.applyAt(hit.point, radius, strength);
}
}
setIso(iso) {
if (!this.editor) return;
this.editor.setIso(iso);
this.rebuildMesh();
}
undo() {
if (!this.editor) return;
if (this.editor.undo()) this.rebuildMesh();
}
exportObj() {
if (!this.lastMesh) return null;
return writeObj(this.lastMesh);
}
exportSidecar() {
if (!this.editor) return null;
return encodeSidecar({
resolution: this.editor.resolution,
bounds: this.editor.bounds,
iso: this.editor.iso,
density: this.editor.density,
});
}
onBuilt(fn) { this._listeners.built.push(fn); }
/**
* For walking-mode: return a collider strategy or null if no mesh exists.
*/
getCollider() {
if (!this.bvh) return null;
return { kind: 'mesh', bvh: this.bvh, bounds: this.editor.bounds };
}
destroy() {
if (this.meshEntity) {
this.meshEntity.destroy();
this.meshEntity = null;
}
this.editor = null;
this.bvh = null;
this.lastMesh = null;
}
// ---------- internals ----------
rebuildMesh() {
if (!this.editor) return;
const mesh = marchingCubes({
density: this.editor.density,
resolution: this.editor.resolution,
bounds: this.editor.bounds,
iso: this.editor.iso,
});
this.lastMesh = mesh;
this.bvh = buildBvh(mesh.positions, mesh.indices);
this._updatePlayCanvasMesh(mesh);
for (const fn of this._listeners.built) {
try { fn({ triCount: mesh.indices.length / 3, iso: this.editor.iso }); }
catch (e) { console.error(e); }
}
}
_updatePlayCanvasMesh(mesh) {
if (this.meshEntity) {
this.meshEntity.destroy();
this.meshEntity = null;
}
if (mesh.indices.length === 0) return;
const device = this.app.graphicsDevice;
const pcMesh = new Mesh(device);
pcMesh.setPositions(mesh.positions);
pcMesh.setNormals(mesh.normals);
pcMesh.setIndices(mesh.indices);
pcMesh.update(PRIMITIVE_TRIANGLES);
const mat = new StandardMaterial();
mat.diffuse = new Color(0.4, 0.7, 1.0);
mat.opacity = 0.35;
mat.blendType = BLEND_NORMAL;
mat.cull = CULLFACE_NONE; // 2-sided so MC normal direction doesn't matter
mat.useLighting = true;
mat.update();
const meshInstance = new MeshInstance(pcMesh, mat);
const entity = new Entity('collision-mesh');
entity.addComponent('render', { meshInstances: [meshInstance] });
this.app.root.addChild(entity);
this.meshEntity = entity;
}
_raycastFromScreen(screenX, screenY) {
if (!this.bvh) return null;
const canvas = this.app.graphicsDevice.canvas;
const rect = canvas.getBoundingClientRect();
const localX = screenX - rect.left;
const localY = screenY - rect.top;
const camComp = this.camera.camera;
const near = camComp.screenToWorld(localX, localY, camComp.nearClip);
const far = camComp.screenToWorld(localX, localY, camComp.farClip);
const dir = [far.x - near.x, far.y - near.y, far.z - near.z];
return raycast(this.bvh, [near.x, near.y, near.z], dir);
}
}