feat: Integrate @react-three/rapier for physics simulation and add conveyor collider functionality

- Added @react-three/rapier to package.json for physics support.
- Refactored AssetBoundingBox to utilize RigidBody for collision detection.
- Implemented ConveyorCollider to manage object movement on conveyor belts.
- Enhanced Model component to include rigid body references and bounding box calculations.
- Updated Ground component to use RigidBody for ground physics.
- Introduced Colliders component to manage material instances with physics interactions.
- Created SecondaryCamera for enhanced camera management and editing capabilities.
- Added secondary canvas for rendering secondary camera views.
- Updated selection controls to utilize bounding boxes for asset selection.
This commit is contained in:
2025-07-17 14:31:43 +05:30
parent 65ef6839a0
commit 660e21edcc
17 changed files with 756 additions and 71 deletions

70
app/package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@react-three/drei": "^9.113.0",
"@react-three/fiber": "^8.17.7",
"@react-three/postprocessing": "^2.16.3",
"@react-three/rapier": "^1.5.0",
"@recast-navigation/core": "^0.39.0",
"@recast-navigation/three": "^0.39.0",
"@testing-library/jest-dom": "^5.17.0",
@@ -2026,7 +2027,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
@@ -2038,7 +2039,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@@ -2371,6 +2372,12 @@
"ms": "^2.1.1"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.14.0.tgz",
"integrity": "sha512-/uHrUzS+CRQ+NQrrJCEDUkhwHlNsAAexbNXgbN9sHY+GwR+SFFAFrxRr8Llf5/AJZzqiLANdQIfJ63Cw4gJVqw==",
"license": "Apache-2.0"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -3804,6 +3811,21 @@
"three": ">=0.144.0"
}
},
"node_modules/@react-three/rapier": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-1.5.0.tgz",
"integrity": "sha512-gylk2KyCer9EoymFyTyc+g2IqyAq4mTbZgaHoSJi6gHoXlJsC2LVeN4jedvegvjUsXPExdE60wHjCPa+DS4iXw==",
"dependencies": {
"@dimforge/rapier3d-compat": "0.14.0",
"suspend-react": "^0.1.3",
"three-stdlib": "^2.29.4"
},
"peerDependencies": {
"@react-three/fiber": ">=8.9.0",
"react": ">=18.0.0",
"three": ">=0.139.0"
}
},
"node_modules/@recast-navigation/core": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/@recast-navigation/core/-/core-0.39.0.tgz",
@@ -4180,6 +4202,26 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
@@ -4291,25 +4333,25 @@
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true
"devOptional": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
"devOptional": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
"devOptional": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
"devOptional": true
},
"node_modules/@turf/along": {
"version": "7.2.0",
@@ -9063,7 +9105,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
"devOptional": true
},
"node_modules/cross-env": {
"version": "7.0.3",
@@ -9940,7 +9982,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.3.1"
}
@@ -15324,7 +15366,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
"devOptional": true
},
"node_modules/makeerror": {
"version": "1.0.12",
@@ -20801,7 +20843,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -20844,7 +20886,7 @@
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"devOptional": true,
"dependencies": {
"acorn": "^8.11.0"
},
@@ -20856,7 +20898,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
"devOptional": true
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
@@ -21352,7 +21394,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
"devOptional": true
},
"node_modules/v8-to-istanbul": {
"version": "8.1.1",
@@ -22411,7 +22453,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=6"
}

View File

@@ -10,6 +10,7 @@
"@react-three/csg": "^3.2.0",
"@react-three/drei": "^9.113.0",
"@react-three/fiber": "^8.17.7",
"@react-three/rapier": "^1.5.0",
"@react-three/postprocessing": "^2.16.3",
"@recast-navigation/core": "^0.39.0",
"@recast-navigation/three": "^0.39.0",

View File

@@ -1,20 +1,26 @@
import { Box3, BoxGeometry, EdgesGeometry, Vector3 } from "three";
import { RigidBody } from "@react-three/rapier";
import { useMemo } from "react";
export const AssetBoundingBox = ({ boundingBox }: { boundingBox: Box3 | null }) => {
if (!boundingBox) return null;
export const AssetBoundingBox = ({ boundingBox }: { boundingBox: Box3 }) => {
const size = boundingBox.getSize(new Vector3());
const center = boundingBox.getCenter(new Vector3());
const boxGeometry = new BoxGeometry(size.x, size.y, size.z);
const edges = new EdgesGeometry(boxGeometry);
const boxGeometry = useMemo(() => new BoxGeometry(size.x, size.y, size.z), [size]);
const edges = useMemo(() => new EdgesGeometry(boxGeometry), [boxGeometry]);
return (
<group name='Asset FallBack'>
<lineSegments position={center}>
<RigidBody includeInvisible type="fixed" colliders="cuboid" position={center.toArray()} rotation={[0, 0, 0]}>
<lineSegments>
<bufferGeometry attach="geometry" {...edges} />
<lineBasicMaterial depthWrite={false} attach="material" color="gray" linewidth={1} />
</lineSegments>
</group>
<mesh visible={false}>
<boxGeometry args={[size.x, size.y, size.z]} />
<meshStandardMaterial transparent opacity={0} />
</mesh>
</RigidBody>
);
};
};

View File

@@ -0,0 +1,148 @@
import * as THREE from 'three';
import { CollisionPayload, RapierRigidBody, RigidBody } from '@react-three/rapier';
import { useEffect, useRef, useState } from 'react';
import { useFrame } from '@react-three/fiber';
function ConveyorCollider({ boundingBox, asset, conveyorPlaneSize, onReachEnd }: {
boundingBox: THREE.Box3 | null,
asset: Asset,
conveyorPlaneSize: [number, number] | null,
onReachEnd?: (rigidBody: RapierRigidBody) => void
}) {
const conveyorRef = useRef<any>(null);
const [objectsOnConveyor, setObjectsOnConveyor] = useState<Set<any>>(new Set());
const conveyorDirection = useRef<THREE.Vector3>(new THREE.Vector3());
const conveyorSpeed = 2;
const reached = useRef<Set<RapierRigidBody>>(new Set());
useEffect(() => {
if (!boundingBox || !conveyorPlaneSize) return;
const [width, depth] = conveyorPlaneSize;
if (width < depth) {
conveyorDirection.current.set(0, 0, 1);
} else {
conveyorDirection.current.set(1, 0, 0);
}
const rotation = new THREE.Euler().fromArray(asset.rotation || [0, 0, 0]);
conveyorDirection.current.applyEuler(rotation);
}, [boundingBox, conveyorPlaneSize, asset.rotation]);
const handleMaterialEnter = (e: CollisionPayload) => {
if (e.other.rigidBody) {
setObjectsOnConveyor(prev => {
const newSet = new Set(prev);
newSet.add(e.other.rigidBody);
return newSet;
});
}
};
const handleMaterialExit = (e: CollisionPayload) => {
if (e.other.rigidBody) {
setObjectsOnConveyor(prev => {
const newSet = new Set(prev);
newSet.delete(e.other.rigidBody);
return newSet;
});
}
};
// useFrame(() => {
// const forward = conveyorDirection.current.clone().normalize();
// const side = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); // perpendicular vector
// const force = forward.clone().multiplyScalar(conveyorSpeed);
// objectsOnConveyor.forEach(rigidBody => {
// if (rigidBody) {
// const position = rigidBody.translation();
// const centerLine = conveyorRef.current.translation();
// const relative = new THREE.Vector3().subVectors(position, centerLine);
// const sideOffset = relative.dot(side);
// const centeringStrength = 10;
// const centeringForce = side.clone().multiplyScalar(-sideOffset * centeringStrength);
// const totalForce = force.clone().add(centeringForce);
// rigidBody.setAngvel(new THREE.Vector3(0, 0, 0), true);
// rigidBody.setLinvel(totalForce, true);
// }
// });
// });
useFrame(() => {
if (
!boundingBox ||
!conveyorPlaneSize ||
!conveyorRef.current ||
typeof conveyorRef.current.translation !== 'function'
) return;
const forward = conveyorDirection.current.clone().normalize();
const side = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const force = forward.clone().multiplyScalar(conveyorSpeed);
const start = conveyorRef.current.translation();
const conveyorLength = Math.max(conveyorPlaneSize[0], conveyorPlaneSize[1]);
const halfLength = conveyorLength / 2;
objectsOnConveyor.forEach(rigidBody => {
if (!rigidBody) return;
const position = rigidBody.translation();
const relative = new THREE.Vector3().subVectors(position, start);
const forwardOffset = relative.dot(forward);
const sideOffset = relative.dot(side);
const atEnd = forwardOffset >= halfLength - 0.5;
if (!atEnd) {
const centeringStrength = 10;
const centeringForce = side.clone().multiplyScalar(-sideOffset * centeringStrength);
const totalForce = force.clone().add(centeringForce);
rigidBody.setAngvel(new THREE.Vector3(0, 0, 0), true);
rigidBody.setLinvel(totalForce, true);
} else {
rigidBody.setLinvel(new THREE.Vector3(0, 0, 0), true);
rigidBody.setAngvel(new THREE.Vector3(0, 0, 0), true);
if (!reached.current.has(rigidBody)) {
reached.current.add(rigidBody);
console.log("✅ Triggering spawn from conveyor");
onReachEnd?.(rigidBody); // ✅ trigger here
}
}
});
});
return (
<>
{asset.eventData?.type === 'Conveyor' && conveyorPlaneSize && (
<RigidBody
ref={conveyorRef}
type="fixed"
position={[0, (boundingBox?.max.y ?? 0) + 0.1, 0]}
rotation={[Math.PI / 2, 0, 0]}
sensor
userData={{ isConveyor: true }}
onIntersectionEnter={handleMaterialEnter}
onIntersectionExit={handleMaterialExit}
colliders="cuboid"
>
<mesh>
<planeGeometry args={conveyorPlaneSize} />
<meshBasicMaterial
color="green"
transparent
opacity={0.3}
visible={false}
/>
</mesh>
</RigidBody>
)}
</>
);
}
export default ConveyorCollider;

View File

@@ -1,6 +1,8 @@
import * as THREE from 'three';
import { SkeletonUtils } from 'three-stdlib';
import { useCallback, useEffect, useRef, useState } from 'react';
import { retrieveGLTF, storeGLTF } from '../../../../../utils/indexDB/idbUtils';
import { RapierRigidBody, RigidBody } from '@react-three/rapier';
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import { ThreeEvent, useFrame, useThree } from '@react-three/fiber';
@@ -15,14 +17,14 @@ import { useParams } from 'react-router-dom';
import { getUserData } from '../../../../../functions/getUserData';
import { useSceneContext } from '../../../../scene/sceneContext';
import { useVersionContext } from '../../../version/versionContext';
import { SkeletonUtils } from 'three-stdlib';
import { useAnimationPlaySpeed } from '../../../../../store/usePlayButtonStore';
import { upsertProductOrEventApi } from '../../../../../services/simulation/products/UpsertProductOrEventApi';
import { getAssetIksApi } from '../../../../../services/simulation/ik/getAssetIKs';
import ConveyorCollider from './conveyorCollider';
function Model({ asset }: { readonly asset: Asset }) {
const url_Backend_dwinzo = `http://${process.env.REACT_APP_SERVER_MARKETPLACE_URL}`;
const { camera, controls, gl } = useThree();
const { camera, controls, gl, scene } = useThree();
const { activeTool } = useActiveTool();
const { toolMode } = useToolMode();
const { toggleView } = useToggleView();
@@ -50,7 +52,9 @@ function Model({ asset }: { readonly asset: Asset }) {
const [isRendered, setIsRendered] = useState(false);
const [gltfScene, setGltfScene] = useState<GLTF["scene"] | null>(null);
const [boundingBox, setBoundingBox] = useState<THREE.Box3 | null>(null);
const [conveyorPlaneSize, setConveyorPlaneSize] = useState<[number, number] | null>(null);
const groupRef = useRef<THREE.Group>(null);
const rigidBodyRef = useRef<RapierRigidBody>(null);
const mixerRef = useRef<THREE.AnimationMixer>();
const actions = useRef<{ [name: string]: THREE.AnimationAction }>({});
const [previousAnimation, setPreviousAnimation] = useState<string | null>(null);
@@ -181,6 +185,11 @@ function Model({ asset }: { readonly asset: Asset }) {
const calculateBoundingBox = (scene: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(scene);
setBoundingBox(box);
if (asset.eventData?.type === 'Conveyor') {
const size = box.getSize(new THREE.Vector3());
setConveyorPlaneSize([size.x, size.z]);
}
};
loadModel();
@@ -464,7 +473,7 @@ function Model({ asset }: { readonly asset: Asset }) {
position={asset.position}
rotation={asset.rotation}
visible={asset.isVisible}
userData={{ ...asset, iks: ikData }}
userData={{ ...asset, iks: ikData, rigidBodyRef: rigidBodyRef.current, boundingBox: boundingBox }}
onDoubleClick={(e) => {
e.stopPropagation();
if (!toggleView) {
@@ -495,11 +504,38 @@ function Model({ asset }: { readonly asset: Asset }) {
}}
>
{gltfScene && (
isRendered ? (
<primitive object={gltfScene} />
) : (
<AssetBoundingBox boundingBox={boundingBox} />
)
<>
{isRendered ? (
<>
<RigidBody
type="fixed"
colliders='cuboid'
ref={rigidBodyRef}
>
<primitive object={gltfScene} />
</ RigidBody>
</>
) : (
<>
{boundingBox &&
<AssetBoundingBox boundingBox={boundingBox} />
}
</>
)}
<ConveyorCollider boundingBox={boundingBox}
asset={asset}
conveyorPlaneSize={conveyorPlaneSize}
onReachEnd={(rigidBody) => {
// Option A: Reset the same object
rigidBody.setTranslation({ x: 0, y: 10, z: 0 }, true);
rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true);
rigidBody.setAngvel({ x: 0, y: 0, z: 0 }, true);
rigidBody.wakeUp();
// Option B: You can also call a function that sets a state to "add a new rigid body"
}} />
</>
)}
</group>
);

View File

@@ -36,10 +36,8 @@ const BoundingBox = ({ boundingBoxRef, isPerAsset = true }: BoundingBoxProps) =>
return selectedAssets.map((obj: THREE.Object3D) => {
const position = obj.position;
const rotation = obj.getWorldQuaternion(new THREE.Quaternion());
const clone = obj.clone();
clone.position.set(0, 0, 0);
clone.rotation.set(0, 0, 0);
const box = new THREE.Box3().setFromObject(clone);
const box: THREE.Box3 = obj.userData.boundingBox ?? new THREE.Box3().setFromObject(obj);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
@@ -57,12 +55,17 @@ const BoundingBox = ({ boundingBoxRef, isPerAsset = true }: BoundingBoxProps) =>
};
});
} else {
const box = new THREE.Box3();
selectedAssets.forEach((obj: any) => box.expandByObject(obj.clone()));
const unionBox = new THREE.Box3();
selectedAssets.forEach((obj: any) => {
const localBox: THREE.Box3 = obj.userData.boundingBox ?? new THREE.Box3().setFromObject(obj);
unionBox.union(localBox);
});
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
unionBox.getSize(size);
unionBox.getCenter(center);
const halfSize = size.clone().multiplyScalar(0.5);
const min = center.clone().sub(halfSize);

View File

@@ -1,3 +1,4 @@
import { RigidBody } from "@react-three/rapier";
import { useTileDistance, useToggleView } from "../../../store/builder/store";
import * as CONSTANTS from "../../../types/world/worldConstants";
@@ -6,7 +7,7 @@ const Ground = ({ plane }: any) => {
const { planeValue, gridValue } = useTileDistance();
return (
<mesh name="Ground">
<>
<mesh
name="Grid"
position={!toggleView ? CONSTANTS.gridConfig.position3D : CONSTANTS.gridConfig.position2D}
@@ -20,17 +21,23 @@ const Ground = ({ plane }: any) => {
]}
/>
</mesh>
<mesh
ref={plane}
rotation-x={CONSTANTS.planeConfig.rotation}
position={!toggleView ? CONSTANTS.planeConfig.position3D : CONSTANTS.planeConfig.position2D}
name="Plane"
receiveShadow
<RigidBody
type="fixed"
colliders='cuboid'
>
<planeGeometry args={[planeValue.width, planeValue.height]} />
<meshBasicMaterial color={CONSTANTS.planeConfig.color} />
</mesh>
</mesh>
<mesh
ref={plane}
rotation-x={CONSTANTS.planeConfig.rotation}
position={!toggleView ? CONSTANTS.planeConfig.position3D : CONSTANTS.planeConfig.position2D}
name="Plane"
receiveShadow
>
<planeGeometry args={[planeValue.width, planeValue.height]} />
<meshBasicMaterial color={CONSTANTS.planeConfig.color} />
</mesh>
</RigidBody>
</>
);
};

View File

@@ -0,0 +1,112 @@
// import { RigidBody, RapierRigidBody } from '@react-three/rapier'
// import { useRef } from 'react';
// import { useLoadingProgress } from '../../../store/builder/store'
// import { MaterialModel } from '../../simulation/materials/instances/material/materialModel';
// function Colliders() {
// const { loadingProgress } = useLoadingProgress();
// const rigidBodyRef = useRef<RapierRigidBody>(null);
// const handleSleep = () => {
// console.log("slept");
// const body = rigidBodyRef.current;
// if (body) {
// body.setTranslation({ x: 0, y: 10, z: 0 }, true);
// body.setLinvel({ x: 0, y: 0, z: 0 }, true);
// body.setAngvel({ x: 0, y: 0, z: 0 }, true);
// body.wakeUp();
// }
// };
// return (
// <>
// {loadingProgress === 0 && (
// <RigidBody
// ref={rigidBodyRef}
// position={[0, 10, 0]}
// colliders="cuboid"
// angularDamping={5}
// linearDamping={1}
// restitution={0.1}
// onSleep={handleSleep}
// >
// <MaterialModel materialId='123' materialType={"Default material"} />
// </RigidBody>
// )}
// </>
// )
// }
// export default Colliders;
import { RigidBody, RapierRigidBody } from '@react-three/rapier';
import { useEffect, useRef, useState } from 'react';
import { useLoadingProgress } from '../../../store/builder/store';
import { MaterialModel } from '../../simulation/materials/instances/material/materialModel';
import { generateUniqueId } from '../../../functions/generateUniqueId';
import { useFrame } from '@react-three/fiber';
type MaterialInstance = {
id: string;
};
function Colliders() {
const { loadingProgress } = useLoadingProgress();
const [materials, setMaterials] = useState<MaterialInstance[]>([]);
const spawnNewMaterial = () => {
setMaterials(prev => [...prev, { id: generateUniqueId() }]);
};
useEffect(() => {
if (loadingProgress === 0 && materials.length === 0) {
spawnNewMaterial(); // spawn one initially
}
}, [loadingProgress]);
return (
<>
{materials.map((mat) => (
<SingleMaterial
key={mat.id}
onReachEnd={spawnNewMaterial}
/>
))}
</>
);
}
export default Colliders;
function SingleMaterial({ onReachEnd }: { onReachEnd: () => void }) {
const rigidBodyRef = useRef<RapierRigidBody>(null);
const hasReachedEnd = useRef(false);
useFrame(() => {
const body = rigidBodyRef.current;
if (!body || hasReachedEnd.current) return;
const position = body.translation();
if (position && position.z > 5) {
console.log('✅ Reached end — triggering spawn');
hasReachedEnd.current = true;
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
onReachEnd();
body.setTranslation({ x: 0, y: -100, z: 0 }, true);
}
});
return (
<RigidBody
ref={rigidBodyRef}
position={[0, 10, 0]}
colliders="cuboid"
angularDamping={5}
linearDamping={1}
restitution={0.1}
>
<MaterialModel materialId='123' materialType='Default material' />
</RigidBody>
);
}

View File

@@ -14,6 +14,8 @@ import { getAllProjects } from "../../services/dashboard/getAllProjects";
import { getUserData } from "../../functions/getUserData";
import { useLoadingProgress, useSocketStore } from "../../store/builder/store";
import { Color } from "three";
import { Physics } from "@react-three/rapier";
import Colliders from "./physics/colliders";
export default function Scene({ layout }: { readonly layout: 'Main Layout' | 'Comparison Layout' }) {
const map = useMemo(() => [
@@ -71,8 +73,12 @@ export default function Scene({ layout }: { readonly layout: 'Main Layout' | 'Co
>
<Setup />
<Collaboration />
<Builder />
<Simulation />
<Physics gravity={[0, -9.81, 0]} debug >
<Builder />
<Simulation />
<Colliders />
</Physics>
<Visualization />
</Canvas>
</KeyboardControls>

View File

@@ -5,6 +5,7 @@ import Controls from '../controls/controls';
import { Environment } from '@react-three/drei'
import background from "../../../assets/textures/hdr/mudroadpuresky2k.hdr";
import SecondaryCamera from '../../secondaryCamera/secondaryCamera';
function Setup() {
return (
@@ -20,6 +21,7 @@ function Setup() {
{/* <MovingClouds /> */}
<Environment files={background} environmentIntensity={1.5} />
<SecondaryCamera/>
</>
)
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useRef } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import { CameraControls, Html, PerspectiveCamera, PivotControls } from '@react-three/drei';
import * as THREE from 'three';
import {
useSecondaryCameraData,
useSecondaryCameraEdit,
useSecondaryCameraState,
} from '../../store/builder/store';
type CameraData = {
id: any;
name: string;
position: [number, number, number];
target: [number, number, number];
};
function SecondaryCameraView() {
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const helperRef = useRef<THREE.CameraHelper | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const dummyMeshRef = useRef<THREE.Mesh | null>(null);
const { scene, size, controls } = useThree();
const { secondaryCameraData, setSecondaryCameraData, updateSecondaryCameraData } = useSecondaryCameraData();
const { selectedSecondaryCamera, setSelectedSecondaryCamera } = useSecondaryCameraState();
const { secondaryCameraEdit } = useSecondaryCameraEdit();
// Sync main controls to selected camera
useEffect(() => {
if (selectedSecondaryCamera) {
(controls as CameraControls).setLookAt(
...selectedSecondaryCamera.position,
...selectedSecondaryCamera.target
);
}
}, [selectedSecondaryCamera?.id]);
// Setup WebGLRenderer
useEffect(() => {
const secondaryCanvas = document.getElementById('secondary-canvas');
if (!secondaryCanvas) return;
const customRenderer = new THREE.WebGLRenderer({
canvas: secondaryCanvas,
antialias: true,
alpha: true,
});
customRenderer.setSize(
secondaryCanvas.getBoundingClientRect().width,
secondaryCanvas.getBoundingClientRect().height
);
customRenderer.setPixelRatio(window.devicePixelRatio);
rendererRef.current = customRenderer;
return () => {
customRenderer.dispose();
};
}, [size]);
// Camera helper
useEffect(() => {
if (selectedSecondaryCamera && cameraRef.current) {
const helper = new THREE.CameraHelper(cameraRef.current);
helperRef.current = helper;
scene.add(helper);
return () => {
scene.remove(helper);
helper.geometry.dispose();
(helper.material as any).dispose?.();
};
}
}, [scene, selectedSecondaryCamera?.id]);
// Render to secondary canvas
useFrame(() => {
if (rendererRef.current && cameraRef.current && selectedSecondaryCamera) {
rendererRef.current.render(scene, cameraRef.current);
}
});
// Live update camera if editing enabled
useFrame(() => {
if (secondaryCameraEdit && selectedSecondaryCamera && cameraRef.current) {
const target = (controls as CameraControls).getTarget(new THREE.Vector3());
const position = (controls as CameraControls).getPosition(new THREE.Vector3());
updateSecondaryCameraData(selectedSecondaryCamera.id, {
position: position.toArray(),
target: target.toArray(),
});
setSelectedSecondaryCamera({
id: selectedSecondaryCamera.id,
position: position.toArray(),
target: target.toArray(),
});
}
});
function handleCamera() {
const target = (controls as CameraControls).getTarget(new THREE.Vector3());
const position = (controls as CameraControls).getPosition(new THREE.Vector3());
const newCameraData: CameraData = {
id: secondaryCameraData.length + 1,
name: `Camera ${secondaryCameraData.length + 1}`,
position: position.toArray(),
target: target.toArray(),
};
setSecondaryCameraData([...secondaryCameraData, newCameraData]);
}
return (
<>
<Html
className='secondary-camera-controls'
style={{
zIndex: 10,
position: 'absolute',
bottom: 350,
right: 0,
border: '1px solid black',
backgroundColor: 'black',
}}
>
<button onClick={handleCamera}>+Add Camera</button>
</Html>
{selectedSecondaryCamera && (
<PerspectiveCamera
ref={cameraRef}
position={selectedSecondaryCamera.position}
onUpdate={(self) => self.lookAt(...selectedSecondaryCamera.target)}
>
<PivotControls
anchor={[0, 0, 0]}
onDrag={() => {
if (dummyMeshRef.current) {
const worldPos = new THREE.Vector3();
dummyMeshRef.current.getWorldPosition(worldPos);
const updatedPos: [number, number, number] = worldPos.toArray() as [number, number, number];
updateSecondaryCameraData(selectedSecondaryCamera.id, {
position: updatedPos,
});
setSelectedSecondaryCamera({
...selectedSecondaryCamera,
position: updatedPos,
});
}
}}
>
<mesh ref={dummyMeshRef}>
<sphereGeometry args={[0.1, 32, 32]} />
<meshBasicMaterial color="yellow" />
</mesh>
</PivotControls>
</PerspectiveCamera>
)}
</>
);
}
export default SecondaryCameraView;

View File

@@ -0,0 +1,46 @@
import React, { useEffect } from 'react'
import { useSecondaryCameraData, useSecondaryCameraEdit, useSecondaryCameraState } from '../../store/builder/store'
type CameraData = {
id: any
name: string
position: [number, number, number]
target: [number, number, number]
}
function SecondaryCanvas() {
const { secondaryCameraData } = useSecondaryCameraData()
const { selectedSecondaryCamera, setSelectedSecondaryCamera } = useSecondaryCameraState();
const { setSecondaryCameraEdit, secondaryCameraEdit } = useSecondaryCameraEdit()
function handleSelectCamera(camera: any) {
const selectedCamera = camera
if (!selectedCamera || (selectedSecondaryCamera && selectedCamera.id === selectedSecondaryCamera.id)) {
setSelectedSecondaryCamera(null);
} else {
setSelectedSecondaryCamera({
id: selectedCamera.id,
position: selectedCamera.position,
target: selectedCamera.target
});
}
}
return (
<>
{secondaryCameraData.map((camera: CameraData) => (
<div key={camera.id} className='secondary-camera-item' onClick={() => handleSelectCamera(camera)} style={{ zIndex: 10, position: 'absolute', bottom: 300 - (camera.id * 30), right: 0, border: '1px solid black', backgroundColor: 'black' }}>
<p>{camera.name}</p>
</div>
))}
<div onClick={() => { setSecondaryCameraEdit(!secondaryCameraEdit) }} style={{ zIndex: 10, position: 'absolute', bottom: 500, right: 0, border: '1px solid black', backgroundColor: secondaryCameraEdit ? 'black' : "white", color: !secondaryCameraEdit ? "black" : "white", padding: '10px' }}>
Edit
</div>
<div className='secondary-canvas-container' style={{ zIndex: 10, position: 'absolute', bottom: 0, right: 0, width: '300px', height: '200px' }}>
<canvas id='secondary-canvas' className='secondary-canvas' style={{ height: "100%", width: "100%", background: "gray" }}></canvas>
</div>
</>
)
}
export default SecondaryCanvas

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useFrame, useThree } from '@react-three/fiber';
import { RapierRigidBody } from '@react-three/rapier';
import * as THREE from 'three';
import { Line } from '@react-three/drei';
import { useAnimationPlaySpeed, usePauseButtonStore, usePlayButtonStore, useResetButtonStore } from '../../../../../store/usePlayButtonStore';
@@ -28,6 +29,7 @@ function HumanAnimator({ path, handleCallBack, currentPhase, human, reset, start
const [objectRotation, setObjectRotation] = useState<[number, number, number] | null>(human.point?.action?.pickUpPoint?.rotation || [0, 0, 0])
const [restRotation, setRestingRotation] = useState<boolean>(true);
const [currentPath, setCurrentPath] = useState<[number, number, number][]>([]);
const rigidBodyRef = useRef<RapierRigidBody>(null);
const { scene } = useThree();
useEffect(() => {

View File

@@ -19,7 +19,7 @@ type ModelType = keyof typeof modelPaths;
interface ModelProps extends React.ComponentProps<'group'> {
materialId: string;
materialType: ModelType;
matRef: React.Ref<THREE.Group<THREE.Object3DEventMap>>
matRef?: React.Ref<THREE.Group<THREE.Object3DEventMap>>
}
export function MaterialModel({ materialId, materialType, matRef, ...props }: Readonly<ModelProps>) {

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { Line } from '@react-three/drei';
import { RapierRigidBody } from '@react-three/rapier';
import { useAnimationPlaySpeed, usePauseButtonStore, usePlayButtonStore, useResetButtonStore } from '../../../../../store/usePlayButtonStore';
import { useSceneContext } from '../../../../scene/sceneContext';
@@ -28,8 +29,15 @@ function VehicleAnimator({ path, handleCallBack, currentPhase, agvUuid, agvDetai
const [objectRotation, setObjectRotation] = useState<{ x: number; y: number; z: number } | undefined>(agvDetail.point?.action?.pickUpPoint?.rotation || { x: 0, y: 0, z: 0 })
const [restRotation, setRestingRotation] = useState<boolean>(true);
const [currentPath, setCurrentPath] = useState<[number, number, number][]>([]);
const rigidBodyRef = useRef<RapierRigidBody>(null);
const { scene } = useThree();
const object = scene.getObjectByProperty('uuid', agvUuid);
if (object?.userData.rigidBodyRef) {
(rigidBodyRef as React.MutableRefObject<RapierRigidBody | null>).current = object.userData.rigidBodyRef;
}
useEffect(() => {
if (currentPhase === 'stationed-pickup' && path.length > 0) {
setCurrentPath(path);
@@ -56,11 +64,17 @@ function VehicleAnimator({ path, handleCallBack, currentPhase, agvUuid, agvDetai
progressRef.current = 0;
setReset(false);
setRestingRotation(true);
const object = scene.getObjectByProperty('uuid', agvUuid);
const vehicle = getVehicleById(agvDetail.modelUuid);
if (object && vehicle) {
object.position.set(vehicle.position[0], vehicle.position[1], vehicle.position[2]);
object.rotation.set(vehicle.rotation[0], vehicle.rotation[1], vehicle.rotation[2]);
if (rigidBodyRef.current && vehicle) {
rigidBodyRef.current.setTranslation(
{ x: vehicle.position[0], y: vehicle.position[1], z: vehicle.position[2] },
true
);
rigidBodyRef.current.setRotation(
{ x: vehicle.rotation[0], y: vehicle.rotation[1], z: vehicle.rotation[2], w: 1 },
true
);
}
}
}, [isReset, isPlaying])
@@ -72,8 +86,7 @@ function VehicleAnimator({ path, handleCallBack, currentPhase, agvUuid, agvDetai
const delta = (now - lastTimeRef.current) / 1000;
lastTimeRef.current = now;
const object = scene.getObjectByProperty('uuid', agvUuid);
if (!object || currentPath.length < 2) return;
if (!rigidBodyRef.current || currentPath.length < 2) return;
if (isPaused) return;
let totalDistance = 0;
@@ -100,21 +113,42 @@ function VehicleAnimator({ path, handleCallBack, currentPhase, agvUuid, agvDetai
const end = new THREE.Vector3(...currentPath[index + 1]);
const segmentDistance = distances[index];
const targetQuaternion = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().lookAt(start, end, new THREE.Vector3(0, 1, 0)));
const targetQuaternion = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().lookAt(start, end, new THREE.Vector3(0, 1, 0))
);
const y180 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
targetQuaternion.multiply(y180);
const angle = object.quaternion.angleTo(targetQuaternion);
const currentRotation = rigidBodyRef.current.rotation();
const currentQuaternion = new THREE.Quaternion(
currentRotation.x,
currentRotation.y,
currentRotation.z,
currentRotation.w
);
const angle = currentQuaternion.angleTo(targetQuaternion);
if (angle < 0.01) {
object.quaternion.copy(targetQuaternion);
rigidBodyRef.current.setRotation(
{ x: targetQuaternion.x, y: targetQuaternion.y, z: targetQuaternion.z, w: targetQuaternion.w },
true
);
} else {
const step = rotationSpeed * delta * speed * agvDetail.speed;
const angle = object.quaternion.angleTo(targetQuaternion);
const angle = currentQuaternion.angleTo(targetQuaternion);
if (angle < step) {
object.quaternion.copy(targetQuaternion);
rigidBodyRef.current.setRotation(
{ x: targetQuaternion.x, y: targetQuaternion.y, z: targetQuaternion.z, w: targetQuaternion.w },
true
);
} else {
object.quaternion.rotateTowards(targetQuaternion, step);
const newQuaternion = currentQuaternion.clone().rotateTowards(targetQuaternion, step);
rigidBodyRef.current.setRotation(
{ x: newQuaternion.x, y: newQuaternion.y, z: newQuaternion.z, w: newQuaternion.w },
true
);
}
}
@@ -124,30 +158,51 @@ function VehicleAnimator({ path, handleCallBack, currentPhase, agvUuid, agvDetai
progressRef.current += delta * (speed * agvDetail.speed);
const t = (progressRef.current - accumulatedDistance) / segmentDistance;
const position = start.clone().lerp(end, t);
object.position.copy(position);
rigidBodyRef.current.setTranslation(
{ x: position.x, y: position.y, z: position.z },
true
);
}
}
if (progressRef.current >= totalDistance) {
if (restRotation && objectRotation) {
const targetEuler = new THREE.Euler(0, objectRotation.y - agvDetail.point.action.steeringAngle, 0);
const baseQuaternion = new THREE.Quaternion().setFromEuler(targetEuler);
const y180 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
const targetQuaternion = baseQuaternion.multiply(y180);
const angle = object.quaternion.angleTo(targetQuaternion);
const currentRotation = rigidBodyRef.current.rotation();
const currentQuaternion = new THREE.Quaternion(
currentRotation.x,
currentRotation.y,
currentRotation.z,
currentRotation.w
);
const angle = currentQuaternion.angleTo(targetQuaternion);
if (angle < 0.01) {
object.quaternion.copy(targetQuaternion);
rigidBodyRef.current.setRotation(
{ x: targetQuaternion.x, y: targetQuaternion.y, z: targetQuaternion.z, w: targetQuaternion.w },
true
);
setRestingRotation(false);
} else {
const step = rotationSpeed * delta * speed * agvDetail.speed;
const angle = object.quaternion.angleTo(targetQuaternion);
const angle = currentQuaternion.angleTo(targetQuaternion);
if (angle < step) {
object.quaternion.copy(targetQuaternion);
rigidBodyRef.current.setRotation(
{ x: targetQuaternion.x, y: targetQuaternion.y, z: targetQuaternion.z, w: targetQuaternion.w },
true
);
} else {
object.quaternion.rotateTowards(targetQuaternion, step);
const newQuaternion = currentQuaternion.clone().rotateTowards(targetQuaternion, step);
rigidBodyRef.current.setRotation(
{ x: newQuaternion.x, y: newQuaternion.y, z: newQuaternion.z, w: newQuaternion.w },
true
);
}
}
return;

View File

@@ -30,6 +30,7 @@ import { getVersionHistoryApi } from "../services/factoryBuilder/versionControl/
import { useVersionHistoryStore } from "../store/builder/useVersionHistoryStore";
import { VersionProvider } from "../modules/builder/version/versionContext";
import { sharedWithMeProjects } from "../services/dashboard/sharedWithMeProject";
import SecondaryCanvas from "../modules/secondaryCamera/secondaryCanvas";
const Project: React.FC = () => {
let navigate = useNavigate();
@@ -140,6 +141,9 @@ const Project: React.FC = () => {
<ComparisonSceneProvider />
</VersionProvider>
</SceneProvider>
<SecondaryCanvas />
{selectedUser && <FollowPerson />}
{isLogListVisible && (
<RenderOverlay>

View File

@@ -724,3 +724,49 @@ export const useSelectedComment = create<any>((set: any) => ({
commentPositionState: null,
setCommentPositionState: (x: any) => set({ commentPositionState: x }),
}));
type CameraData = {
id: any;
name: string;
position: [number, number, number];
target: [number, number, number];
};
type SecondaryCameraStore = {
secondaryCameraData: CameraData[];
setSecondaryCameraData: (data: CameraData[]) => void;
updateSecondaryCameraData: (id: any, updatedData: Partial<CameraData>) => void;
};
export const useSecondaryCameraData = create<SecondaryCameraStore>((set) => ({
secondaryCameraData: [],
setSecondaryCameraData: (data) => set({ secondaryCameraData: data }),
updateSecondaryCameraData: (id, updatedData) =>
set((state) => ({
secondaryCameraData: state.secondaryCameraData.map((camera) =>
camera.id === id ? { ...camera, ...updatedData } : camera
),
})),
}));
type SecondaryCamera = {
id: number;
position: [number, number, number];
target: [number, number, number];
};
type SecondaryCameraState = {
selectedSecondaryCamera: SecondaryCamera | null;
setSelectedSecondaryCamera: (camera: SecondaryCamera | null) => void;
};
export const useSecondaryCameraState = create<SecondaryCameraState>((set) => ({
selectedSecondaryCamera: null,
setSelectedSecondaryCamera: (camera) => set({ selectedSecondaryCamera: camera }),
}));
export const useSecondaryCameraEdit = create<any>((set) => ({
secondaryCameraEdit: false,
setSecondaryCameraEdit: (x: boolean) => set({ secondaryCameraEdit: x }),
}));