import React, { useState, useEffect, useRef } from 'react'; import { Canvas, useFrame } from '@react-three/fiber'; import { MOUSE } from 'three'; import { OrbitControls, Grid, GizmoHelper, GizmoViewcube } from '@react-three/drei'; import { Sidebar } from './components/Sidebar'; import { IOPanel } from './components/IOPanel'; import { LadderEditor } from './components/LadderEditor'; import { SimObject, ObjectType, AxisObject, CylinderObject, SwitchObject, LedObject, IOLogicRule, LogicCondition, LogicAction, ProjectData } from './types'; import { EditableObject } from './components/SceneObjects'; import { Layout as LayoutIcon, Cpu, Play, Pause, Square, Download, Upload, Magnet } from 'lucide-react'; // --- Simulation Manager (PLC Scan Cycle) --- const SimulationLoop = ({ isPlaying, objects, setObjects, logicRules, manualInputs, manualOutputs, setInputs, setOutputs }: { isPlaying: boolean, objects: SimObject[], setObjects: React.Dispatch>, logicRules: IOLogicRule[], manualInputs: boolean[], manualOutputs: boolean[], setInputs: (inputs: boolean[]) => void, setOutputs: (outputs: boolean[]) => void }) => { useFrame((state, delta) => { if (!isPlaying) return; // --- 1. PLC Input Scan (Physical + Manual Force) --- const sensorInputs = new Array(16).fill(false); objects.forEach(obj => { if (obj.type === ObjectType.SWITCH && (obj as SwitchObject).isOn) { const port = (obj as SwitchObject).inputPort; if (port >= 0 && port < 16) sensorInputs[port] = true; } if ((obj.type === ObjectType.AXIS_LINEAR || obj.type === ObjectType.AXIS_ROTARY) && (obj as AxisObject).triggers) { const axis = obj as AxisObject; axis.triggers.forEach(trig => { const met = trig.condition === '>' ? axis.currentValue > trig.position : axis.currentValue < trig.position; if (met && trig.targetInputPort >= 0 && trig.targetInputPort < 16) sensorInputs[trig.targetInputPort] = true; }); } }); const effectiveInputs = sensorInputs.map((bit, i) => bit || manualInputs[i]); setInputs(effectiveInputs); // --- 2. PLC Logic Execution --- const logicOutputs = new Array(16).fill(false); logicRules.forEach(rule => { if (!rule.enabled) return; const inputState = effectiveInputs[rule.inputPort]; const conditionMet = rule.condition === LogicCondition.IS_ON ? inputState : !inputState; if (conditionMet && rule.action === LogicAction.SET_ON) { logicOutputs[rule.outputPort] = true; } }); // Effective Outputs = Logic OR Manual Force const effectiveOutputs = logicOutputs.map((bit, i) => bit || manualOutputs[i]); setOutputs(effectiveOutputs); // --- 3. Physical Update Scan --- setObjects(prevObjects => { let hasChanges = false; const newObjects = prevObjects.map(obj => { let newObj = { ...obj }; let changed = false; if (obj.type === ObjectType.LED) { const led = obj as LedObject; const shouldBeOn = effectiveOutputs[led.outputPort]; if (led.isOn !== shouldBeOn) { newObj = { ...led, isOn: shouldBeOn }; changed = true; } } else if (obj.type === ObjectType.CYLINDER) { const cyl = obj as CylinderObject; const shouldExtend = effectiveOutputs[cyl.outputPort]; const targetPos = shouldExtend ? cyl.stroke : 0; if (Math.abs(cyl.currentPosition - targetPos) > 0.01) { const step = cyl.speed * delta * 5; if (cyl.currentPosition < targetPos) { newObj = { ...cyl, extended: shouldExtend, currentPosition: Math.min(targetPos, cyl.currentPosition + step) }; } else { newObj = { ...cyl, extended: shouldExtend, currentPosition: Math.max(targetPos, cyl.currentPosition - step) }; } changed = true; } } else if (obj.type === ObjectType.AXIS_LINEAR || obj.type === ObjectType.AXIS_ROTARY) { const axis = obj as AxisObject; if (axis.currentValue !== axis.targetValue) { const diff = axis.targetValue - axis.currentValue; const step = axis.speed * delta * 10; if (Math.abs(diff) < step) { newObj = { ...axis, currentValue: axis.targetValue }; } else { newObj = { ...axis, currentValue: axis.currentValue + Math.sign(diff) * step }; } changed = true; } } if (changed) hasChanges = true; return newObj; }); return hasChanges ? newObjects : prevObjects; }); }); return null; }; export default function App() { const [activeView, setActiveView] = useState<'layout' | 'logic'>('layout'); const [objects, setObjects] = useState([]); const [logicRules, setLogicRules] = useState([]); const [inputs, setInputs] = useState(new Array(16).fill(false)); const [outputs, setOutputs] = useState(new Array(16).fill(false)); const [manualInputs, setManualInputs] = useState(new Array(16).fill(false)); const [manualOutputs, setManualOutputs] = useState(new Array(16).fill(false)); const [inputNames, setInputNames] = useState(new Array(16).fill('')); const [outputNames, setOutputNames] = useState(new Array(16).fill('')); const [selectedId, setSelectedId] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [isSnapEnabled, setIsSnapEnabled] = useState(false); const controlsRef = useRef(null); const handleSetView = (view: 'TOP' | 'BOTTOM' | 'FRONT' | 'BACK' | 'LEFT' | 'RIGHT') => { const ctrl = controlsRef.current; if (!ctrl) return; const dist = 15; ctrl.target.set(0, 0, 0); switch (view) { case 'TOP': ctrl.object.position.set(0, dist, 0); break; case 'BOTTOM': ctrl.object.position.set(0, -dist, 0); break; case 'FRONT': ctrl.object.position.set(0, 0, dist); break; case 'BACK': ctrl.object.position.set(0, 0, -dist); break; case 'RIGHT': ctrl.object.position.set(dist, 0, 0); break; case 'LEFT': ctrl.object.position.set(-dist, 0, 0); break; } ctrl.update(); }; useEffect(() => { if (!isPlaying) { setObjects(prev => prev.map(obj => { if (obj.type === ObjectType.LED) { const led = obj as LedObject; return { ...led, isOn: manualOutputs[led.outputPort] }; } if (obj.type === ObjectType.CYLINDER) { const cyl = obj as CylinderObject; const extended = manualOutputs[cyl.outputPort]; return { ...cyl, extended, currentPosition: extended ? cyl.stroke : 0 }; } return obj; })); setInputs([...manualInputs]); setOutputs([...manualOutputs]); } }, [manualInputs, manualOutputs, isPlaying]); const handleUpdatePortName = (type: 'input' | 'output', index: number, name: string) => { if (type === 'input') { const next = [...inputNames]; next[index] = name; setInputNames(next); } else { const next = [...outputNames]; next[index] = name; setOutputNames(next); } }; const handleToggleManualInput = (index: number) => { const next = [...manualInputs]; next[index] = !next[index]; setManualInputs(next); }; const handleToggleManualOutput = (index: number) => { const next = [...manualOutputs]; next[index] = !next[index]; setManualOutputs(next); }; const handleReset = () => { setIsPlaying(false); setManualInputs(new Array(16).fill(false)); setManualOutputs(new Array(16).fill(false)); setObjects(prev => prev.map(o => { if (o.type === ObjectType.AXIS_LINEAR || o.type === ObjectType.AXIS_ROTARY) return { ...o, currentValue: (o as AxisObject).min, targetValue: (o as AxisObject).min }; if (o.type === ObjectType.CYLINDER) return { ...o, currentPosition: 0, extended: false }; if (o.type === ObjectType.LED) return { ...o, isOn: false }; if (o.type === ObjectType.SWITCH) return { ...o, isOn: false }; return o; })); }; const handleExport = () => { const project: ProjectData = { version: "1.0", objects, logicRules, inputNames, outputNames }; const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `motion-sim-project.json`; a.click(); URL.revokeObjectURL(url); }; const handleImport = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target?.result as string) as ProjectData; if (data.objects) setObjects(data.objects); if (data.logicRules) setLogicRules(data.logicRules); if (data.inputNames) setInputNames(data.inputNames); if (data.outputNames) setOutputNames(data.outputNames); } catch (err) { alert("Invalid JSON"); } }; reader.readAsText(file); e.target.value = ''; }; const handleAddObject = (type: ObjectType) => { const id = crypto.randomUUID(); const position = { x: 0, y: 0.1, z: 0 }; let newObj: SimObject; switch (type) { case ObjectType.AXIS_LINEAR: newObj = { id, type, name: 'Linear Axis', position, rotation: { x: 0, y: 0, z: 0 }, min: 0, max: 100, currentValue: 0, targetValue: 0, speed: 1, isOscillating: false, triggers: [] } as AxisObject; break; case ObjectType.AXIS_ROTARY: newObj = { id, type, name: 'Rotary Axis', position, rotation: { x: 0, y: 0, z: 0 }, min: 0, max: 360, currentValue: 0, targetValue: 0, speed: 1, isOscillating: false, triggers: [] } as AxisObject; break; case ObjectType.CYLINDER: newObj = { id, type, name: 'Cylinder', position, rotation: { x: 0, y: 0, z: 0 }, stroke: 2, extended: false, currentPosition: 0, speed: 2, outputPort: 0 } as CylinderObject; break; case ObjectType.SWITCH: newObj = { id, type, name: 'Switch', position, rotation: { x: 0, y: 0, z: 0 }, isOn: false, isMomentary: true, inputPort: 0 } as SwitchObject; break; case ObjectType.LED: newObj = { id, type, name: 'LED', position, rotation: { x: 0, y: 0, z: 0 }, isOn: false, color: '#00ff00', outputPort: 0 } as LedObject; break; default: return; } setObjects(prev => [...prev, newObj]); setSelectedId(id); setActiveView('layout'); }; return (
{/* Top Navigation */}
M
MotionSim
{/* Global Controls */}
{isPlaying && (
PLC ACTIVE
)}
{/* Main Content Area */}
{/* Left Side: Permanent IO Panel */} {/* Center/Right: Dynamic Content based on tab */} {activeView === 'layout' ? (
{/* View Controls Overlay */}
handleSetView('TOP')} color="text-blue-400" /> handleSetView('BOTTOM')} color="text-blue-400" />
handleSetView('FRONT')} color="text-red-400" /> handleSetView('BACK')} color="text-red-400" />
handleSetView('LEFT')} color="text-green-400" /> handleSetView('RIGHT')} color="text-green-400" />
{objects.map(obj => ( setObjects(prev => prev.map(o => o.id === id ? { ...o, ...updates } as SimObject : o))} onInteract={(id) => setObjects(prev => prev.map(o => (o.id === id && o.type === ObjectType.SWITCH) ? { ...o, isOn: !o.isOn } as SwitchObject : o))} /> ))}
{ setObjects(prev => prev.filter(o => o.id !== id)); setSelectedId(null); }} onUpdateObject={(id, updates) => setObjects(prev => prev.map(o => o.id === id ? { ...o, ...updates } as SimObject : o))} onSelect={setSelectedId} onUpdatePortName={handleUpdatePortName} />
) : ( setLogicRules(prev => [...prev, r])} onDeleteRule={(id) => setLogicRules(prev => prev.filter(r => r.id !== id))} onUpdateRule={(id, updates) => setLogicRules(prev => prev.map(r => r.id === id ? { ...r, ...updates } : r))} /> )}
); } const ViewBtn: React.FC<{ label: string, onClick: () => void, color?: string }> = ({ label, onClick, color = "text-gray-400" }) => ( );