Files
La-Fabrik/src/utils/three/ExplodedModel.ts
T
Tom Boullay d9a92e336c fix(repair): drill explosion to natural group + apply mission rotation
- ExplodedModel.createParts now descends recursively through single
  mesh-bearing wrapper nodes (e.g. Scene > Moto > Eclatement) until
  reaching a node with multiple mesh-bearing children. Previously the
  first wrapper was used as root, so models with extra Empty/group
  parents fell back to flat leaf meshes lerping in local space.
- Add optional modelRotation field on RepairMissionConfig so fragmented
  + repairing models can match the world-space rotation of the source
  inspection model (parked Ebike).
- Ebike mission now uses EBIKE_WORLD_ROTATION_Y/EBIKE_WORLD_SCALE
  directly so the fragmented bike lines up with the parked bike.
2026-06-02 22:51:35 +02:00

121 lines
3.3 KiB
TypeScript

import * as THREE from "three";
export interface ExplodedPart {
object: THREE.Object3D;
originalPosition: THREE.Vector3;
targetPosition: THREE.Vector3;
}
interface ExplodedModelOptions {
distance?: number;
speed?: number;
}
const _center = new THREE.Vector3();
const _direction = new THREE.Vector3();
export class ExplodedModel {
private readonly parts: ExplodedPart[] = [];
private readonly distance: number;
private readonly speed: number;
private progress = 0;
private targetProgress = 0;
constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
this.distance = options.distance ?? 1.2;
this.speed = options.speed ?? 6;
this.parts = this.createParts(model);
}
setSplit(split: boolean): void {
this.targetProgress = split ? 1 : 0;
}
getParts(): readonly ExplodedPart[] {
return this.parts;
}
update(delta: number): void {
const diff = this.targetProgress - this.progress;
if (Math.abs(diff) < 0.001) {
this.progress = this.targetProgress;
} else {
this.progress += diff * Math.min(delta * this.speed, 1);
}
this.parts.forEach((part) => {
part.object.position.lerpVectors(
part.originalPosition,
part.targetPosition,
this.progress,
);
});
}
private createParts(model: THREE.Object3D): ExplodedPart[] {
// Drill down through single-mesh-bearing branches until we find a node
// with multiple mesh-bearing children (the natural "explosion group" the
// modeler authored). Falls back to flat mesh list only if no such group
// exists. This avoids exploding leaves in local space when wrapper nodes
// (e.g. "Empty" + "Moto" > "Eclatement") sit above the actual group.
let current = model;
while (true) {
const meshChildren = current.children.filter((child) => hasMesh(child));
if (meshChildren.length === 1 && meshChildren[0]) {
current = meshChildren[0];
continue;
}
break;
}
const directChildren = current.children.filter((child) => hasMesh(child));
const sourceObjects =
directChildren.length > 1 ? directChildren : getMeshes(current);
if (sourceObjects.length === 0) return [];
_center.set(0, 0, 0);
sourceObjects.forEach((object) => _center.add(object.position));
_center.divideScalar(sourceObjects.length);
return sourceObjects.map((object, index) => {
const originalPosition = object.position.clone();
_direction.subVectors(originalPosition, _center);
if (_direction.lengthSq() < 0.0001) {
const angle = (index / sourceObjects.length) * Math.PI * 2;
_direction.set(Math.cos(angle), 0.25, Math.sin(angle));
}
_direction.normalize();
return {
object,
originalPosition,
targetPosition: originalPosition
.clone()
.addScaledVector(_direction, this.distance),
};
});
}
}
function hasMesh(object: THREE.Object3D): boolean {
let found = false;
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
found = true;
}
});
return found;
}
function getMeshes(object: THREE.Object3D): THREE.Object3D[] {
const meshes: THREE.Object3D[] = [];
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child);
}
});
return meshes;
}