272 lines
9.4 KiB
TypeScript
272 lines
9.4 KiB
TypeScript
import { useRef, useEffect, forwardRef, useMemo, useState } from "react";
|
|
import * as THREE from "three";
|
|
import { useFrame, useThree, createPortal } from "@react-three/fiber";
|
|
import { MaterialFactory } from "../materials/MaterialFactory";
|
|
import { useTexturePipeline } from "../hooks/useTexturePipeline";
|
|
|
|
interface ModelViewerProps {
|
|
object: THREE.Object3D | null;
|
|
sceneObjects: any[];
|
|
}
|
|
|
|
interface MeshMaterialManagerProps {
|
|
mesh: THREE.Mesh;
|
|
sceneObj: any;
|
|
textureCache: React.MutableRefObject<Map<string, THREE.Texture>>;
|
|
}
|
|
|
|
const MeshMaterialManager = ({ mesh, sceneObj, textureCache }: MeshMaterialManagerProps) => {
|
|
useEffect(() => {
|
|
if (!mesh || !sceneObj.materialProps) return;
|
|
|
|
const props = sceneObj.materialProps;
|
|
const mappingType = props.mappingType || 'uv';
|
|
const uvScale = props.uvScale || 1;
|
|
const uvTiling = props.uvTiling || [1, 1];
|
|
const uvOffset = props.uvOffset || [0, 0];
|
|
const uvRotation = props.uvRotation || 0;
|
|
|
|
const origMat = (Array.isArray(mesh.material) ? mesh.material[0] : mesh.material) as THREE.MeshPhysicalMaterial;
|
|
|
|
const baseParams: any = {
|
|
color: origMat.color,
|
|
roughness: origMat.roughness !== undefined ? origMat.roughness : 0.5,
|
|
metalness: origMat.metalness !== undefined ? origMat.metalness : 0,
|
|
map: origMat.map,
|
|
normalMap: origMat.normalMap,
|
|
roughnessMap: origMat.roughnessMap,
|
|
metalnessMap: origMat.metalnessMap,
|
|
aoMap: origMat.aoMap,
|
|
emissiveMap: origMat.emissiveMap,
|
|
emissive: origMat.emissive,
|
|
emissiveIntensity: origMat.emissiveIntensity,
|
|
envMapIntensity: origMat.envMapIntensity,
|
|
alphaMap: origMat.alphaMap,
|
|
transparent: origMat.transparent,
|
|
opacity: origMat.opacity,
|
|
side: origMat.side,
|
|
alphaTest: origMat.alphaTest,
|
|
};
|
|
|
|
// Use MaterialFactory to get reusable materials
|
|
let activeMat: any;
|
|
if (mappingType === 'triplanar') {
|
|
activeMat = MaterialFactory.getInstance().getTriplanarMaterial(
|
|
baseParams,
|
|
mesh.uuid
|
|
);
|
|
|
|
activeMat.triplanarUniforms.uTriplanarScale.value = uvScale;
|
|
activeMat.triplanarUniforms.uBlendSharpness.value = 5.0; // Default or from props
|
|
} else {
|
|
activeMat = MaterialFactory.getInstance().getStandardUVMaterial(
|
|
baseParams,
|
|
mesh.uuid
|
|
);
|
|
|
|
let modeIndex = 0;
|
|
if (mappingType === 'planar') modeIndex = 1;
|
|
else if (mappingType === 'box') modeIndex = 2;
|
|
else if (mappingType === 'cylinder') modeIndex = 3;
|
|
|
|
activeMat.uvUniforms.uMappingMode.value = modeIndex;
|
|
activeMat.updateUVTransform(uvOffset, [uvTiling[0] * uvScale, uvTiling[1] * uvScale], uvRotation * (Math.PI / 180));
|
|
}
|
|
|
|
// Apply basic props
|
|
if (props.color) activeMat.color.set(props.color);
|
|
if (props.roughness !== undefined) activeMat.roughness = props.roughness;
|
|
if (props.metalness !== undefined) activeMat.metalness = props.metalness;
|
|
if (props.opacity !== undefined) activeMat.opacity = props.opacity;
|
|
if (props.transmission !== undefined) activeMat.transmission = props.transmission;
|
|
activeMat.transparent = (props.transmission > 0) || (props.opacity < 1) || !!props.alphaMap;
|
|
|
|
if (Array.isArray(mesh.material)) {
|
|
mesh.material = mesh.material.map(() => activeMat);
|
|
} else {
|
|
mesh.material = activeMat;
|
|
}
|
|
|
|
// Textures
|
|
const textureTypes: ('map' | 'normalMap' | 'specularMap' | 'alphaMap')[] = ['map', 'normalMap', 'specularMap', 'alphaMap'];
|
|
textureTypes.forEach(type => {
|
|
const url = props[type];
|
|
if (url) {
|
|
if (!activeMat[type] || activeMat[type]!.name !== url) {
|
|
if (textureCache.current.has(url)) {
|
|
activeMat[type] = textureCache.current.get(url)!;
|
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = activeMat[type];
|
|
activeMat.needsUpdate = true;
|
|
} else {
|
|
const loader = new THREE.TextureLoader();
|
|
loader.load(url, (tex) => {
|
|
tex.name = url;
|
|
if (type === 'map') tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
|
textureCache.current.set(url, tex);
|
|
activeMat[type] = tex;
|
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = tex;
|
|
activeMat.needsUpdate = true;
|
|
});
|
|
}
|
|
}
|
|
} else if (url === null && activeMat[type]) {
|
|
activeMat[type] = null;
|
|
if (mappingType === 'triplanar') activeMat.triplanarUniforms['t' + type.charAt(0).toUpperCase() + type.slice(1).replace('Map', '')].value = null;
|
|
activeMat.needsUpdate = true;
|
|
}
|
|
});
|
|
|
|
mesh.visible = sceneObj.visible;
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
mesh.frustumCulled = false;
|
|
|
|
// Sync individual mesh transform
|
|
if (sceneObj.transformProps) {
|
|
const { position, rotation, scale } = sceneObj.transformProps;
|
|
mesh.position.set(position[0], position[1], position[2]);
|
|
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
|
|
mesh.scale.set(scale[0], scale[1], scale[2]);
|
|
}
|
|
|
|
}, [sceneObj, mesh]);
|
|
|
|
return null;
|
|
};
|
|
|
|
const FillMesh = ({ fillProps, originalMesh }: { fillProps: any, originalMesh: THREE.Mesh }) => {
|
|
const materialRef = useRef<any>(null);
|
|
const [clippingPlane] = useState(() => new THREE.Plane(new THREE.Vector3(0, -1, 0), 0));
|
|
const { size: winSize } = useThree();
|
|
|
|
// Clone geometry and fix interpolation
|
|
const geometry = useMemo(() => {
|
|
const geo = originalMesh.geometry.clone();
|
|
geo.computeVertexNormals();
|
|
return geo;
|
|
}, [originalMesh.geometry]);
|
|
|
|
// Update clipping plane based on height
|
|
useEffect(() => {
|
|
geometry.computeBoundingBox();
|
|
const box = geometry.boundingBox || new THREE.Box3().setFromObject(originalMesh);
|
|
const minY = box.min.y;
|
|
const maxY = box.max.y;
|
|
// height is 0 to 1
|
|
const clipY = minY + (maxY - minY) * (fillProps.height ?? 0.5);
|
|
// Since this plane is applied in world space by Three.js by default,
|
|
// wait, localClippingEnabled = true means the plane is in LOCAL space!
|
|
// If it's local space, clipY is exact.
|
|
clippingPlane.constant = clipY;
|
|
}, [geometry, fillProps.height, originalMesh]);
|
|
|
|
useFrame((state) => {
|
|
if (materialRef.current && fillProps.type === 'liquid') {
|
|
const mat = materialRef.current;
|
|
if (mat.uTime !== undefined) mat.uTime = state.clock.elapsedTime;
|
|
if (mat.uResolution && mat.uResolution.set) {
|
|
mat.uResolution.set(winSize.width || 800, winSize.height || 600);
|
|
}
|
|
}
|
|
});
|
|
|
|
const fillNode = (
|
|
<mesh geometry={geometry} scale={[0.995, 0.995, 0.995]}>
|
|
{fillProps.type === 'liquid' ? (
|
|
// @ts-ignore
|
|
<liquidMaterialImpl
|
|
ref={materialRef}
|
|
uColor={new THREE.Color(fillProps.color)}
|
|
transparent
|
|
side={THREE.DoubleSide}
|
|
clippingPlanes={[clippingPlane]}
|
|
clipping={true}
|
|
/>
|
|
) : (
|
|
<meshPhysicalMaterial
|
|
color={fillProps.color}
|
|
side={THREE.DoubleSide}
|
|
clippingPlanes={[clippingPlane]}
|
|
roughness={0.2}
|
|
clearcoat={0.5}
|
|
/>
|
|
)}
|
|
</mesh>
|
|
);
|
|
|
|
return createPortal(fillNode, originalMesh);
|
|
};
|
|
|
|
export const ModelViewer = forwardRef<THREE.Group, ModelViewerProps>(({ object, sceneObjects }, ref) => {
|
|
const internalRef = useRef<THREE.Group>(null);
|
|
const groupRef = (ref as any) || internalRef;
|
|
const textureCache = useRef<Map<string, THREE.Texture>>(new Map());
|
|
const meshCache = useRef<Map<string, THREE.Mesh>>(new Map());
|
|
|
|
useTexturePipeline(object);
|
|
|
|
// Cleanup textures on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
textureCache.current.forEach(tex => tex.dispose());
|
|
textureCache.current.clear();
|
|
meshCache.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
// Build mesh cache when object changes
|
|
useEffect(() => {
|
|
meshCache.current.clear();
|
|
if (object) {
|
|
let mIdx = 0;
|
|
object.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
const stableId = `mesh-${mIdx}`;
|
|
meshCache.current.set(stableId, child);
|
|
mIdx++;
|
|
}
|
|
});
|
|
}
|
|
}, [object]);
|
|
|
|
// Main transform update for the core model
|
|
useEffect(() => {
|
|
if (object && sceneObjects) {
|
|
const mainModel = sceneObjects.find(obj => obj.type === 'model');
|
|
if (mainModel && mainModel.transformProps && groupRef.current) {
|
|
const { position: p, rotation: r, scale: s } = mainModel.transformProps;
|
|
groupRef.current.position.set(p[0], p[1], p[2]);
|
|
groupRef.current.rotation.set(r[0], r[1], r[2]);
|
|
groupRef.current.scale.set(s[0], s[1], s[2]);
|
|
}
|
|
}
|
|
}, [object, sceneObjects]);
|
|
|
|
if (!object) return null;
|
|
|
|
const meshObjects = sceneObjects.filter(obj => obj.type === 'mesh');
|
|
|
|
return (
|
|
<group ref={groupRef}>
|
|
<primitive object={object} />
|
|
{meshObjects.map(obj => {
|
|
const mesh = meshCache.current.get(obj.id);
|
|
if (!mesh) return null;
|
|
return (
|
|
<group key={obj.id}>
|
|
<MeshMaterialManager
|
|
mesh={mesh}
|
|
sceneObj={obj}
|
|
textureCache={textureCache}
|
|
/>
|
|
{obj.fillProps?.enabled && (
|
|
<FillMesh fillProps={obj.fillProps} originalMesh={mesh} />
|
|
)}
|
|
</group>
|
|
);
|
|
})}
|
|
</group>
|
|
);
|
|
});
|