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 { SystemSetupDialog } from './components/SystemSetupDialog'; import { SimObject, ObjectType, AxisObject, CylinderObject, SwitchObject, LedObject, IOLogicRule, LogicCondition, LogicAction, ProjectData, AxisData, LogicTriggerType, LogicActionType } from './types'; import { EditableObject } from './components/SceneObjects'; import { Layout as LayoutIcon, Cpu, Play, Pause, Square, Download, Upload, Magnet, Settings } from 'lucide-react'; // --- Simulation Manager (PLC Scan Cycle) --- const SimulationLoop = ({ isPlaying, objects, setObjects, logicRules, manualInputs, manualOutputs, setInputs, setOutputs, axes, setAxes }: { isPlaying: boolean, objects: SimObject[], setObjects: React.Dispatch>, logicRules: IOLogicRule[], manualInputs: boolean[], manualOutputs: boolean[], setInputs: (inputs: boolean[]) => void, setOutputs: (outputs: boolean[]) => void, axes: AxisData[], setAxes: React.Dispatch> }) => { useFrame((state, delta) => { if (!isPlaying) return; // --- 1. PLC Input Scan (Physical + Manual Force) --- const sensorInputs = new Array(32).fill(false); objects.forEach(obj => { if (obj.type === ObjectType.SWITCH && (obj as SwitchObject).isOn) { const port = (obj as SwitchObject).inputPort; if (port >= 0 && port < 32) sensorInputs[port] = true; } if ((obj.type === ObjectType.AXIS_LINEAR || obj.type === ObjectType.AXIS_ROTARY) && (obj as AxisObject).triggers) { const axisObj = obj as AxisObject; const globalAxis = axes[axisObj.axisIndex]; if (globalAxis) { axisObj.triggers.forEach(trig => { const met = trig.condition === '>' ? globalAxis.value > trig.position : globalAxis.value < trig.position; if (met && trig.targetInputPort >= 0 && trig.targetInputPort < 32) sensorInputs[trig.targetInputPort] = true; }); } } }); const effectiveInputs = sensorInputs.map((bit, i) => bit || manualInputs[i]); setInputs(effectiveInputs); // --- 2. PLC Logic Execution --- const logicOutputs = new Array(32).fill(false); // Axis updates from Logic (collected to avoid multiple setState calls) const axisUpdates = new Map(); logicRules.forEach(rule => { if (!rule.enabled) return; // 1. Evaluate Trigger let conditionMet = false; if (rule.triggerType === LogicTriggerType.INPUT_BIT) { const inputState = effectiveInputs[rule.inputPort]; conditionMet = inputState; // Default IS_ON behavior for now } else if (rule.triggerType === LogicTriggerType.AXIS_COMPARE) { const axis = axes[rule.triggerAxisIndex]; if (axis) { switch (rule.triggerCompareOp) { case LogicCondition.GREATER: conditionMet = axis.value > rule.triggerValue; break; case LogicCondition.LESS: conditionMet = axis.value < rule.triggerValue; break; case LogicCondition.EQUAL: conditionMet = Math.abs(axis.value - rule.triggerValue) < 0.1; break; } } } // 2. Execute Action if Trigger Met if (conditionMet) { if (rule.actionType === LogicActionType.OUTPUT_COIL) { if (rule.action === LogicAction.SET_ON) { logicOutputs[rule.outputPort] = true; } } else if (rule.actionType === LogicActionType.AXIS_MOVE) { axisUpdates.set(rule.targetAxisIndex, rule.targetAxisValue); } } }); // Apply Logical Outputs const effectiveOutputs = logicOutputs.map((bit, i) => bit || manualOutputs[i]); setOutputs(effectiveOutputs); // Apply Axis Logic Updates (Only if changed to prevent thrashing) if (axisUpdates.size > 0) { setAxes(prev => prev.map(a => { if (axisUpdates.has(a.id)) { const target = axisUpdates.get(a.id)!; if (a.target !== target) return { ...a, target }; } return a; })); } // --- 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) { // Axis objects are now just visualizers, no internal state update needed here // Their position is derived from global 'axes' state in render } if (changed) hasChanges = true; return newObj; }); return hasChanges ? newObjects : prevObjects; }); // --- 4. Axis Update Scan --- setAxes(prevAxes => { return prevAxes.map(axis => { if (axis.value !== axis.target) { const diff = axis.target - axis.value; const step = axis.speed * delta * 10; if (Math.abs(diff) < step) { return { ...axis, value: axis.target }; } else { return { ...axis, value: axis.value + Math.sign(diff) * step }; } } return axis; }); }); }); 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(32).fill(false)); const [outputs, setOutputs] = useState(new Array(32).fill(false)); const [manualInputs, setManualInputs] = useState(new Array(32).fill(false)); const [manualOutputs, setManualOutputs] = useState(new Array(32).fill(false)); const [inputNames, setInputNames] = useState(new Array(32).fill('')); const [outputNames, setOutputNames] = useState(new Array(32).fill('')); const [selectedId, setSelectedId] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [isSnapEnabled, setIsSnapEnabled] = useState(false); const [isSetupOpen, setIsSetupOpen] = useState(false); const controlsRef = useRef(null); // Initialize 8 Global Axes const [axes, setAxes] = useState(() => Array.from({ length: 8 }, (_, i) => ({ id: i, name: `Axis ${i + 1}`, value: 0, target: 0, speed: 5, type: 'linear' })) ); 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(32).fill(false)); setManualOutputs(new Array(32).fill(false)); setObjects(prev => prev.map(o => { // Reset logic handles axes reset separately, but visualizers don't hold state anymore 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; })); setAxes(prev => prev.map(a => ({ ...a, value: 0, target: 0 }))); }; const handleExport = () => { const project: ProjectData = { version: "1.1", objects, logicRules, inputNames, outputNames, axes }; 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); if (data.axes) setAxes(data.axes); } 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, axisIndex: 0, 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, axisIndex: 0, 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
{/* 4. Top Navigation에 Setup 버튼 추가 */}
{/* 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))} axes={axes} /> ))}
{ 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))} onUpdateAxis={(index, updates) => setAxes(prev => prev.map((a, i) => i === index ? { ...a, ...updates } : a))} 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))} axes={axes} /> )}
setIsSetupOpen(false)} inputNames={inputNames} outputNames={outputNames} axes={axes} onUpdatePortName={handleUpdatePortName} onUpdateAxis={(index, updates) => setAxes(prev => prev.map((a, i) => i === index ? { ...a, ...updates } : a))} />
); } const ViewBtn: React.FC<{ label: string, onClick: () => void, color?: string }> = ({ label, onClick, color = "text-gray-400" }) => ( );