diff --git a/app/src/modules/simulation/process/processAnimator.tsx b/app/src/modules/simulation/process/processAnimator.tsx new file mode 100644 index 0000000..123ec80 --- /dev/null +++ b/app/src/modules/simulation/process/processAnimator.tsx @@ -0,0 +1,416 @@ +import React, { useRef, useState, useEffect, useMemo } from "react"; +import { usePlayButtonStore } from "../../../store/usePlayButtonStore"; +import { GLTFLoader } from "three-stdlib"; +import { useLoader, useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import { GLTF } from "three-stdlib"; +import boxGltb from "../../../assets/gltf-glb/crate_box.glb"; + +interface PointAction { + uuid: string; + name: string; + type: "Inherit" | "Spawn" | "Despawn" | "Delay" | "Swap"; + material: string; + delay: string | number; + spawnInterval: string | number; + isUsed: boolean; +} + +interface ProcessPoint { + uuid: string; + position: number[]; + rotation: number[]; + actions: PointAction[]; + connections: { + source: { pathUUID: string; pointUUID: string }; + targets: { pathUUID: string; pointUUID: string }[]; + }; +} + +interface ProcessPath { + modeluuid: string; + modelName: string; + points: ProcessPoint[]; + pathPosition: number[]; + pathRotation: number[]; + speed: number; +} + +interface ProcessData { + id: string; + paths: ProcessPath[]; + animationPath: { x: number; y: number; z: number }[]; + pointActions: PointAction[][]; + speed: number; +} + +interface AnimationState { + currentIndex: number; + progress: number; + isAnimating: boolean; + speed: number; + isDelaying: boolean; + delayStartTime: number; + currentDelayDuration: number; + delayComplete: boolean; + currentPathIndex: number; + spawnPoints: Record< + string, + { + position: THREE.Vector3; + interval: number; + lastSpawnTime: number; + } + >; +} + +const MAX_SPAWNED_OBJECTS = 20; + +const ProcessAnimator: React.FC<{ processes: ProcessData[] }> = ({ + processes, +}) => { + console.log("processes: ", processes); + const gltf = useLoader(GLTFLoader, boxGltb) as GLTF; + const { isPlaying, setIsPlaying } = usePlayButtonStore(); + + const groupRef = useRef(null); + const meshRef = useRef(null); + const [visible, setVisible] = useState(false); + const spawnedObjectsRef = useRef([]); + + const materials = useMemo( + () => ({ + Wood: new THREE.MeshStandardMaterial({ color: 0x8b4513 }), + Box: new THREE.MeshStandardMaterial({ + color: 0xcccccc, + metalness: 0.8, + roughness: 0.2, + }), + Crate: new THREE.MeshStandardMaterial({ + color: 0x00aaff, + metalness: 0.1, + roughness: 0.5, + }), + Default: new THREE.MeshStandardMaterial({ color: 0x00ff00 }), + }), + [] + ); + + const [currentMaterial, setCurrentMaterial] = useState( + materials.Default + ); + + const { animationPath, currentProcess } = useMemo(() => { + const defaultProcess = { + animationPath: [], + pointActions: [], + speed: 1, + paths: [], + }; + const cp = processes?.[0] || defaultProcess; + return { + animationPath: + cp.animationPath?.map((p) => new THREE.Vector3(p.x, p.y, p.z)) || [], + currentProcess: cp, + }; + }, [processes]); + + const animationStateRef = useRef({ + currentIndex: 0, + progress: 0, + isAnimating: false, + speed: currentProcess.speed, + isDelaying: false, + delayStartTime: 0, + currentDelayDuration: 0, + delayComplete: false, + currentPathIndex: 0, + spawnPoints: {}, + }); + + const getPointDataForAnimationIndex = (index: number) => { + if (!processes[0]?.paths) return null; + + if (index < 3) { + return processes[0].paths[0]?.points[index]; + } else { + const path2Index = index - 3; + return processes[0].paths[1]?.points[path2Index]; + } + }; + + useEffect(() => { + if (isPlaying) { + setVisible(true); + animationStateRef.current = { + currentIndex: 0, + progress: 0, + isAnimating: true, + speed: currentProcess.speed, + isDelaying: false, + delayStartTime: 0, + currentDelayDuration: 0, + delayComplete: false, + currentPathIndex: 0, + spawnPoints: {}, + }; + + // Clear spawned objects + if (groupRef.current) { + spawnedObjectsRef.current.forEach((obj) => { + if (groupRef.current?.children.includes(obj)) { + groupRef.current.remove(obj); + } + if (obj instanceof THREE.Mesh) { + obj.material.dispose(); + } + }); + spawnedObjectsRef.current = []; + } + + const currentRef = gltf?.scene ? groupRef.current : meshRef.current; + if (currentRef && animationPath.length > 0) { + currentRef.position.copy(animationPath[0]); + } + } else { + animationStateRef.current.isAnimating = false; + } + }, [isPlaying, currentProcess, animationPath]); + + const handleMaterialSwap = (materialType: string) => { + const newMaterial = + materials[materialType as keyof typeof materials] || materials.Default; + setCurrentMaterial(newMaterial); + + spawnedObjectsRef.current.forEach((obj) => { + if (obj instanceof THREE.Mesh) { + obj.material = newMaterial.clone(); + } + }); + }; + + const hasNonInheritActions = (actions: PointAction[] = []) => { + return actions.some((action) => action.isUsed && action.type !== "Inherit"); + }; + + const handlePointActions = ( + actions: PointAction[] = [], + currentTime: number, + currentPosition: THREE.Vector3 + ) => { + let shouldStopAnimation = false; + + actions.forEach((action) => { + if (!action.isUsed) return; + + switch (action.type) { + case "Delay": + if ( + !animationStateRef.current.isDelaying && + !animationStateRef.current.delayComplete + ) { + const delayDuration = + typeof action.delay === "number" + ? action.delay + : parseFloat(action.delay as string) || 0; + + if (delayDuration > 0) { + animationStateRef.current.isDelaying = true; + animationStateRef.current.delayStartTime = currentTime; + animationStateRef.current.currentDelayDuration = delayDuration; + shouldStopAnimation = true; + } + } + break; + + case "Despawn": + setVisible(false); + setIsPlaying(false); + animationStateRef.current.isAnimating = false; + shouldStopAnimation = true; + break; + + case "Spawn": + const spawnInterval = + typeof action.spawnInterval === "number" + ? action.spawnInterval + : parseFloat(action.spawnInterval as string) || 1; + + const positionKey = currentPosition.toArray().join(","); + + animationStateRef.current.spawnPoints[positionKey] = { + position: currentPosition.clone(), + interval: spawnInterval, + lastSpawnTime: currentTime - spawnInterval, // Force immediate spawn + }; + break; + + case "Swap": + if (action.material) { + handleMaterialSwap(action.material); + } + break; + + case "Inherit": + break; + } + }); + + return shouldStopAnimation; + }; + + useFrame((state, delta) => { + const currentRef = gltf?.scene ? groupRef.current : meshRef.current; + if ( + !currentRef || + !animationStateRef.current.isAnimating || + animationPath.length < 2 + ) { + return; + } + + const currentTime = state.clock.getElapsedTime(); + const path = animationPath; + const stateRef = animationStateRef.current; + + if (stateRef.currentIndex === 3 && stateRef.currentPathIndex === 0) { + stateRef.currentPathIndex = 1; + } + + const currentPointData = getPointDataForAnimationIndex( + stateRef.currentIndex + ); + + if (stateRef.progress === 0 && currentPointData?.actions) { + const shouldStop = handlePointActions( + currentPointData.actions, + currentTime, + currentRef.position + ); + if (shouldStop) return; + } + + if (stateRef.isDelaying) { + if ( + currentTime - stateRef.delayStartTime >= + stateRef.currentDelayDuration + ) { + stateRef.isDelaying = false; + stateRef.delayComplete = true; + } else { + return; + } + } + + // Handle spawning - this is the key updated part + Object.entries(stateRef.spawnPoints).forEach(([key, spawnPoint]) => { + if (currentTime - spawnPoint.lastSpawnTime >= spawnPoint.interval) { + spawnPoint.lastSpawnTime = currentTime; + + if (gltf?.scene && groupRef?.current) { + const newObject = gltf.scene.clone(); + newObject.position.copy(spawnPoint.position); + + newObject.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.material = currentMaterial.clone(); + } + }); + + groupRef.current.add(newObject); + spawnedObjectsRef.current.push(newObject); + + // Clean up old objects if needed + console.log( + "spawnedObjectsRef.current.length: ", + spawnedObjectsRef.current.length + ); + if (spawnedObjectsRef.current.length > MAX_SPAWNED_OBJECTS) { + const oldest = spawnedObjectsRef.current.shift(); + if (oldest && groupRef.current.children.includes(oldest)) { + groupRef.current.remove(oldest); + if (oldest instanceof THREE.Mesh) { + oldest.material.dispose(); + } + } + } + } + } + }); + + const nextPointIdx = stateRef.currentIndex + 1; + const isLastPoint = nextPointIdx >= path.length; + + if (isLastPoint) { + if (currentPointData?.actions) { + const shouldStop = !hasNonInheritActions(currentPointData.actions); + if (shouldStop) { + currentRef.position.copy(path[stateRef.currentIndex]); + setIsPlaying(false); + stateRef.isAnimating = false; + return; + } + } + } + + if (!isLastPoint) { + const nextPoint = path[nextPointIdx]; + const distance = path[stateRef.currentIndex].distanceTo(nextPoint); + const movement = stateRef.speed * delta; + stateRef.progress += movement / distance; + + if (stateRef.progress >= 1) { + stateRef.currentIndex = nextPointIdx; + stateRef.progress = 0; + stateRef.delayComplete = false; + currentRef.position.copy(nextPoint); + } else { + currentRef.position.lerpVectors( + path[stateRef.currentIndex], + nextPoint, + stateRef.progress + ); + } + } + }); + + useEffect(() => { + return () => { + if (groupRef.current) { + spawnedObjectsRef.current.forEach((obj) => { + if (groupRef.current?.children.includes(obj)) { + groupRef.current.remove(obj); + } + if (obj instanceof THREE.Mesh) { + obj.material.dispose(); + } + }); + spawnedObjectsRef.current = []; + } + }; + }, []); + + if (!processes || processes.length === 0) { + return null; + } + + if (!gltf?.scene) { + return visible ? ( + + + + ) : null; + } + + return visible ? ( + + + + ) : null; +}; + +export default ProcessAnimator; diff --git a/app/src/modules/simulation/process/processContainer.tsx b/app/src/modules/simulation/process/processContainer.tsx new file mode 100644 index 0000000..967376c --- /dev/null +++ b/app/src/modules/simulation/process/processContainer.tsx @@ -0,0 +1,17 @@ +import React, { useState } from "react"; +import ProcessCreator from "./processCreator"; +import ProcessAnimator from "./processAnimator"; + +const ProcessContainer: React.FC = () => { + const [processes, setProcesses] = useState([]); + + + return ( + <> + + {processes.length > 0 && } + + ); +}; + +export default ProcessContainer; diff --git a/app/src/modules/simulation/process/processCreator.tsx b/app/src/modules/simulation/process/processCreator.tsx new file mode 100644 index 0000000..8e1ed1d --- /dev/null +++ b/app/src/modules/simulation/process/processCreator.tsx @@ -0,0 +1,398 @@ +import React, { + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from "react"; +import { useSimulationPaths } from "../../../store/store"; +import * as THREE from "three"; +import { useThree } from "@react-three/fiber"; +import { + ConveyorEventsSchema, + VehicleEventsSchema, +} from "../../../types/world/worldTypes"; + +// Type definitions +export interface PointAction { + uuid: string; + name: string; + type: string; + material: string; + delay: number | string; + spawnInterval: string | number; + isUsed: boolean; +} + +export interface PathPoint { + uuid: string; + position: [number, number, number]; + actions: PointAction[]; + connections: { + targets: Array<{ pathUUID: string }>; + }; +} + +export interface SimulationPath { + modeluuid: string; + points: PathPoint[]; + pathPosition: [number, number, number]; + speed?: number; +} + +export interface Process { + id: string; + paths: SimulationPath[]; + animationPath: THREE.Vector3[]; + pointActions: PointAction[][]; + speed: number; +} + +interface ProcessCreatorProps { + onProcessesCreated: (processes: Process[]) => void; +} + +// Convert event schemas to SimulationPath +function convertToSimulationPath( + path: ConveyorEventsSchema | VehicleEventsSchema +): SimulationPath { + const { modeluuid } = path; + + // Simplified normalizeAction function that preserves exact original properties + const normalizeAction = (action: any): PointAction => { + return { ...action }; // Return exact copy with no modifications + }; + + if (path.type === "Conveyor") { + return { + modeluuid, + points: path.points.map((point) => ({ + uuid: point.uuid, + position: point.position, + actions: point.actions.map(normalizeAction), // Preserve exact actions + connections: { + targets: point.connections.targets.map((target) => ({ + pathUUID: target.pathUUID, + })), + }, + })), + pathPosition: path.position, + speed: + typeof path.speed === "string" + ? parseFloat(path.speed) || 1 + : path.speed || 1, + }; + } else { + return { + modeluuid, + points: [ + { + uuid: path.point.uuid, + position: path.point.position, + actions: Array.isArray(path.point.actions) + ? path.point.actions.map(normalizeAction) + : [normalizeAction(path.point.actions)], + connections: { + targets: path.point.connections.targets.map((target) => ({ + pathUUID: target.pathUUID, + })), + }, + }, + ], + pathPosition: path.position, + speed: path.point.speed || 1, + }; + } +} + +// Custom shallow comparison for arrays +const areArraysEqual = (a: any[], b: any[]) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +}; + +// Helper function to create an empty process +const createEmptyProcess = (): Process => ({ + id: `process-${Math.random().toString(36).substring(2, 11)}`, + paths: [], + animationPath: [], + pointActions: [], + speed: 1, +}); + +// Enhanced connection checking function +function shouldReverseNextPath( + currentPath: SimulationPath, + nextPath: SimulationPath +): boolean { + if (nextPath.points.length !== 3) return false; + + const currentLastPoint = currentPath.points[currentPath.points.length - 1]; + const nextFirstPoint = nextPath.points[0]; + const nextLastPoint = nextPath.points[nextPath.points.length - 1]; + + // Check if current last connects to next last (requires reversal) + const connectsToLast = currentLastPoint.connections.targets.some( + (target) => + target.pathUUID === nextPath.modeluuid && + nextLastPoint.connections.targets.some( + (t) => t.pathUUID === currentPath.modeluuid + ) + ); + + // Check if current last connects to next first (no reversal needed) + const connectsToFirst = currentLastPoint.connections.targets.some( + (target) => + target.pathUUID === nextPath.modeluuid && + nextFirstPoint.connections.targets.some( + (t) => t.pathUUID === currentPath.modeluuid + ) + ); + + // Only reverse if connected to last point and not to first point + return connectsToLast && !connectsToFirst; +} + +// Updated path adjustment function +function adjustPathPointsOrder(paths: SimulationPath[]): SimulationPath[] { + if (paths.length < 2) return paths; + + const adjustedPaths = [...paths]; + + for (let i = 0; i < adjustedPaths.length - 1; i++) { + const currentPath = adjustedPaths[i]; + const nextPath = adjustedPaths[i + 1]; + + if (shouldReverseNextPath(currentPath, nextPath)) { + const reversedPoints = [ + nextPath.points[2], + nextPath.points[1], + nextPath.points[0], + ]; + + adjustedPaths[i + 1] = { + ...nextPath, + points: reversedPoints, + }; + } + } + + return adjustedPaths; +} + +// Main hook for process creation +export function useProcessCreation() { + const { scene } = useThree(); + const [processes, setProcesses] = useState([]); + + const hasSpawnAction = useCallback((path: SimulationPath): boolean => { + return path.points.some((point) => + point.actions.some((action) => action.type.toLowerCase() === "spawn") + ); + }, []); + + const createProcess = useCallback( + (paths: SimulationPath[]): Process => { + if (!paths || paths.length === 0) { + return createEmptyProcess(); + } + + const animationPath: THREE.Vector3[] = []; + const pointActions: PointAction[][] = []; + const processSpeed = paths[0]?.speed || 1; + + for (const path of paths) { + for (const point of path.points) { + const obj = scene.getObjectByProperty("uuid", point.uuid); + if (!obj) { + console.warn(`Object with UUID ${point.uuid} not found in scene`); + continue; + } + + const position = obj.getWorldPosition(new THREE.Vector3()); + animationPath.push(position.clone()); + pointActions.push(point.actions); + } + } + + return { + id: `process-${Math.random().toString(36).substring(2, 11)}`, + paths, + animationPath, + pointActions, + speed: processSpeed, + }; + }, + [scene] + ); + + const getAllConnectedPaths = useCallback( + ( + initialPath: SimulationPath, + allPaths: SimulationPath[], + visited: Set = new Set() + ): SimulationPath[] => { + const connectedPaths: SimulationPath[] = []; + const queue: SimulationPath[] = [initialPath]; + visited.add(initialPath.modeluuid); + + const pathMap = new Map(); + allPaths.forEach((path) => pathMap.set(path.modeluuid, path)); + + while (queue.length > 0) { + const currentPath = queue.shift()!; + connectedPaths.push(currentPath); + + // Process outgoing connections + for (const point of currentPath.points) { + for (const target of point.connections.targets) { + if (!visited.has(target.pathUUID)) { + const targetPath = pathMap.get(target.pathUUID); + if (targetPath) { + visited.add(target.pathUUID); + queue.push(targetPath); + } + } + } + } + + // Process incoming connections + for (const [uuid, path] of pathMap) { + if (!visited.has(uuid)) { + const hasConnectionToCurrent = path.points.some((point) => + point.connections.targets.some( + (t) => t.pathUUID === currentPath.modeluuid + ) + ); + if (hasConnectionToCurrent) { + visited.add(uuid); + queue.push(path); + } + } + } + } + + return connectedPaths; + }, + [] + ); + + const createProcessesFromPaths = useCallback( + (paths: SimulationPath[]): Process[] => { + if (!paths || paths.length === 0) return []; + + const visited = new Set(); + const processes: Process[] = []; + const pathMap = new Map(); + paths.forEach((path) => pathMap.set(path.modeluuid, path)); + + for (const path of paths) { + if (!visited.has(path.modeluuid) && hasSpawnAction(path)) { + const connectedPaths = getAllConnectedPaths(path, paths, visited); + const adjustedPaths = adjustPathPointsOrder(connectedPaths); + const process = createProcess(adjustedPaths); + processes.push(process); + } + } + + return processes; + }, + [createProcess, getAllConnectedPaths, hasSpawnAction] + ); + + return { + processes, + createProcessesFromPaths, + setProcesses, + }; +} + +const ProcessCreator: React.FC = React.memo( + ({ onProcessesCreated }) => { + const { simulationPaths } = useSimulationPaths(); + const { createProcessesFromPaths } = useProcessCreation(); + const prevPathsRef = useRef([]); + const prevProcessesRef = useRef([]); + + const convertedPaths = useMemo((): SimulationPath[] => { + if (!simulationPaths) return []; + return simulationPaths.map((path) => + convertToSimulationPath( + path as ConveyorEventsSchema | VehicleEventsSchema + ) + ); + }, [simulationPaths]); + + const pathsDependency = useMemo(() => { + if (!convertedPaths) return null; + return convertedPaths.map((path) => ({ + id: path.modeluuid, + hasSpawn: path.points.some((p: PathPoint) => + p.actions.some((a: PointAction) => a.type.toLowerCase() === "spawn") + ), + connections: path.points + .flatMap((p: PathPoint) => + p.connections.targets.map((t: { pathUUID: string }) => t.pathUUID) + ) + .join(","), + })); + }, [convertedPaths]); + + useEffect(() => { + if (!convertedPaths || convertedPaths.length === 0) { + if (prevProcessesRef.current.length > 0) { + onProcessesCreated([]); + prevProcessesRef.current = []; + } + return; + } + + if (areArraysEqual(prevPathsRef.current, convertedPaths)) { + return; + } + + prevPathsRef.current = convertedPaths; + const newProcesses = createProcessesFromPaths(convertedPaths); + + // console.log("--- Action Types in Paths ---"); + // convertedPaths.forEach((path) => { + // path.points.forEach((point) => { + // point.actions.forEach((action) => { + // console.log( + // `Path ${path.modeluuid}, Point ${point.uuid}: ${action.type}` + // ); + // }); + // }); + // }); + // console.log("New processes:", newProcesses); + + if ( + newProcesses.length !== prevProcessesRef.current.length || + !newProcesses.every( + (proc, i) => + proc.paths.length === prevProcessesRef.current[i]?.paths.length && + proc.paths.every( + (path, j) => + path.modeluuid === + prevProcessesRef.current[i]?.paths[j]?.modeluuid + ) + ) + ) { + onProcessesCreated(newProcesses); + // prevProcessesRef.current = newProcesses; + } + }, [ + pathsDependency, + onProcessesCreated, + convertedPaths, + createProcessesFromPaths, + ]); + + return null; + } +); + +export default ProcessCreator; diff --git a/app/src/modules/simulation/simulation.tsx b/app/src/modules/simulation/simulation.tsx index 739a92b..6fb0036 100644 --- a/app/src/modules/simulation/simulation.tsx +++ b/app/src/modules/simulation/simulation.tsx @@ -5,6 +5,7 @@ import Behaviour from './behaviour/behaviour'; import PathCreation from './path/pathCreation'; import PathConnector from './path/pathConnector'; import useModuleStore from '../../store/useModuleStore'; +import ProcessContainer from './process/processContainer'; function Simulation() { const { activeModule } = useModuleStore(); @@ -35,6 +36,7 @@ function Simulation() { <> + )}