cube-parameters
Version:
A sophisticated 3D model viewer built with React, TypeScript, and Three.js, featuring advanced visualization tools, measurement capabilities, and lighting controls.
353 lines (294 loc) • 11.6 kB
text/typescript
import { useRef, useCallback, useState, useEffect } from 'react';
import * as THREE from 'three';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import type { LoadedModel } from '../types/model';
export const useFBXLoader = (scene: THREE.Scene | null) => {
const loaderRef = useRef<FBXLoader | null>(null);
const [loadedModels, setLoadedModels] = useState<LoadedModel[]>([]);
const [currentModel, setCurrentModel] = useState<LoadedModel | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeLoadingRef = useRef<AbortController | null>(null);
// Cleanup function for geometries and materials
const disposeObject = useCallback((object: THREE.Object3D) => {
try {
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(material => {
if (material.map) material.map.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.bumpMap) material.bumpMap.dispose();
material.dispose();
});
} else {
if (child.material.map) child.material.map.dispose();
if (child.material.normalMap) child.material.normalMap.dispose();
if (child.material.bumpMap) child.material.bumpMap.dispose();
child.material.dispose();
}
}
}
});
} catch (error) {
console.error('Error disposing object:', error);
}
}, []);
const mergeGeometries = useCallback(async (object: THREE.Object3D): Promise<THREE.Object3D> => {
try {
const geometries: THREE.BufferGeometry[] = [];
const materials: THREE.Material[] = [];
object.traverse((child) => {
if (child instanceof THREE.Mesh && child.geometry) {
// Clone geometry to avoid modifying original
const clonedGeometry = child.geometry.clone();
// Apply the mesh's transformation to the geometry
clonedGeometry.applyMatrix4(child.matrixWorld);
geometries.push(clonedGeometry);
// Collect unique materials
if (Array.isArray(child.material)) {
materials.push(...child.material);
} else {
materials.push(child.material);
}
}
});
if (geometries.length === 0) {
console.warn('No geometries found to merge');
return object;
}
// Merge all geometries into one
let mergedGeometry: THREE.BufferGeometry;
try {
const { mergeGeometries: mergeGeometriesUtil } = await import('three/examples/jsm/utils/BufferGeometryUtils.js');
const merged = mergeGeometriesUtil(geometries);
if (merged) {
mergedGeometry = merged;
} else {
throw new Error('BufferGeometryUtils merge failed');
}
} catch (error) {
console.warn('BufferGeometryUtils not available or failed, using basic merge:', error);
// Basic merge fallback
mergedGeometry = new THREE.BufferGeometry();
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
geometries.forEach(geometry => {
const posAttr = geometry.getAttribute('position');
const normAttr = geometry.getAttribute('normal');
const uvAttr = geometry.getAttribute('uv');
if (posAttr) positions.push(...Array.from(posAttr.array));
if (normAttr) normals.push(...Array.from(normAttr.array));
if (uvAttr) uvs.push(...Array.from(uvAttr.array));
});
if (positions.length > 0) {
mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
}
if (normals.length > 0) {
mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
}
if (uvs.length > 0) {
mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
}
}
// Compute normals if they don't exist
if (!mergedGeometry.getAttribute('normal')) {
mergedGeometry.computeVertexNormals();
}
// Create a single material (use the first non-basic material found)
let finalMaterial = materials.find(mat => !(mat instanceof THREE.MeshBasicMaterial)) || materials[0];
if (!finalMaterial) {
finalMaterial = new THREE.MeshPhongMaterial({ color: 0x888888 });
} else if (finalMaterial instanceof THREE.MeshBasicMaterial) {
// Convert basic material to phong for better lighting
finalMaterial = new THREE.MeshPhongMaterial({
color: finalMaterial.color,
map: finalMaterial.map
});
}
// Create new merged mesh
const mergedMesh = new THREE.Mesh(mergedGeometry, finalMaterial);
mergedMesh.name = object.name || 'Merged Object';
mergedMesh.castShadow = true;
mergedMesh.receiveShadow = true;
// Clean up original geometries to prevent memory leaks
geometries.forEach(geo => {
try {
geo.dispose();
} catch (error) {
console.warn('Error disposing geometry:', error);
}
});
return mergedMesh;
} catch (error) {
console.error('Error in mergeGeometries:', error);
return object;
}
}, []);
const loadFBXModel = useCallback(async (file: File) => {
console.log('Starting FBX load process:', file.name);
if (!scene) {
console.error('Scene not available for FBX loading');
setError('3D scene not ready. Please try again.');
return;
}
// Cancel any existing loading operation
if (activeLoadingRef.current) {
activeLoadingRef.current.abort();
}
const abortController = new AbortController();
activeLoadingRef.current = abortController;
setIsLoading(true);
setError(null);
try {
// Validate file
if (!file.name.toLowerCase().endsWith('.fbx')) {
throw new Error('Invalid file format. Please select an FBX file.');
}
if (file.size > 50 * 1024 * 1024) { // 50MB limit
throw new Error('File too large. Please select a file smaller than 50MB.');
}
if (!loaderRef.current) {
loaderRef.current = new FBXLoader();
console.log('FBX loader initialized');
}
console.log('Reading file as array buffer...');
const arrayBuffer = await file.arrayBuffer();
// Check if operation was aborted
if (abortController.signal.aborted) {
console.log('FBX loading aborted');
return;
}
console.log('Parsing FBX data...');
const object = loaderRef.current.parse(arrayBuffer, '');
// Check if operation was aborted after parsing
if (abortController.signal.aborted) {
console.log('FBX loading aborted after parsing');
disposeObject(object);
return;
}
console.log('FBX parsed successfully:', object);
// Fix Z-axis orientation - most FBX files are Y-up, convert to Z-up
object.rotateX(-Math.PI / 2);
// Update world matrix before merging
object.updateMatrixWorld(true);
// Merge geometries to create unified object
console.log('Merging geometries...');
const mergedObject = await mergeGeometries(object);
// Check if operation was aborted after merging
if (abortController.signal.aborted) {
console.log('FBX loading aborted after merging');
disposeObject(mergedObject);
return;
}
// Calculate bounding box after merging
const boundingBox = new THREE.Box3().setFromObject(mergedObject);
const center = boundingBox.getCenter(new THREE.Vector3());
const size = boundingBox.getSize(new THREE.Vector3());
// Center the model at origin
mergedObject.position.sub(center);
// Scale model to fit in view (max size of 4 units)
const maxDimension = Math.max(size.x, size.y, size.z);
const scale = maxDimension > 4 ? 4 / maxDimension : 1;
mergedObject.scale.setScalar(scale);
// Ensure shadows are enabled
mergedObject.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
// Create a Group to ensure proper typing
const modelGroup = new THREE.Group();
modelGroup.add(mergedObject);
modelGroup.name = file.name.replace('.fbx', '');
const modelData: LoadedModel = {
id: Date.now().toString(),
name: file.name.replace('.fbx', ''),
object: modelGroup,
boundingBox: new THREE.Box3().setFromObject(modelGroup),
size: file.size
};
// Remove previous model if exists
if (currentModel) {
console.log('Removing previous model from scene');
scene.remove(currentModel.object);
disposeObject(currentModel.object);
}
console.log('Adding merged model to scene');
scene.add(modelGroup);
setLoadedModels(prev => [...prev, modelData]);
setCurrentModel(modelData);
// Dispose original object to free memory
if (object !== mergedObject) {
disposeObject(object);
}
} catch (err) {
if (!abortController.signal.aborted) {
console.error('Failed to load FBX model:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to load model. Please check the file format.';
setError(errorMessage);
}
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
activeLoadingRef.current = null;
}
}, [scene, currentModel, mergeGeometries, disposeObject]);
const switchToModel = useCallback((modelId: string) => {
if (!scene) return;
const model = loadedModels.find(m => m.id === modelId);
if (!model) return;
// Remove current model
if (currentModel) {
scene.remove(currentModel.object);
}
// Add selected model
scene.add(model.object);
setCurrentModel(model);
}, [scene, loadedModels, currentModel]);
const removeModel = useCallback((modelId: string) => {
if (!scene) return;
const model = loadedModels.find(m => m.id === modelId);
if (!model) return;
if (currentModel?.id === modelId) {
scene.remove(model.object);
setCurrentModel(null);
}
// Dispose of the model to free memory
disposeObject(model.object);
setLoadedModels(prev => prev.filter(m => m.id !== modelId));
}, [scene, loadedModels, currentModel, disposeObject]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Cancel any active loading
if (activeLoadingRef.current) {
activeLoadingRef.current.abort();
}
// Dispose all loaded models
loadedModels.forEach(model => {
try {
disposeObject(model.object);
} catch (error) {
console.warn('Error disposing model on cleanup:', error);
}
});
};
}, [loadedModels, disposeObject]);
return {
loadedModels,
currentModel,
isLoading,
error,
loadFBXModel,
switchToModel,
removeModel
};
};