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, LogicMathOp, MEMORY_SIZE, ADDR_AXIS_BASE, ADDR_AXIS_STRIDE, OFF_AXIS_CURRENT_POS, OFF_AXIS_TARGET_POS } from './types'; import { EditableObject } from './components/SceneObjects'; import { MemoryViewer } from './components/MemoryViewer'; import { Layout as LayoutIcon, Cpu, Play, Pause, Square, Download, Upload, Magnet, Settings, HardDrive } from 'lucide-react'; // --- Simulation Manager (PLC Scan Cycle) --- const SimulationLoop = ({ isPlaying, objects, setObjects, logicRules, manualInputs, manualOutputs, setInputs, setOutputs, axes, setAxes, memoryView }: { 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>, memoryView: React.MutableRefObject }) => { 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); // --- 1. Memory IO Scan (Axis Global State -> Memory) --- // Update Memory Mapped Axis Areas (Read Current Pos from App State) // In a real PLC, this happens at the start of scan. axes.forEach(axis => { const base = ADDR_AXIS_BASE + (axis.id * ADDR_AXIS_STRIDE); // 0: Status (Uint16) - reserved memoryView.current.setUint16(base, 0, true); // 2: Current Pos (Float32) memoryView.current.setFloat32(base + OFF_AXIS_CURRENT_POS, axis.value, true); // 10: Speed (Float32) memoryView.current.setFloat32(base + 10, axis.speed, true); // Sync Target to memory if it wasn't written by logic, or initialize it // (Optional: In strict PLC, memory drives axis. If we want UI slider to work, UI must write to memory OR axis. Logic takes precedence) // Here we write the *current* target to memory so logic sees it, unless logic overwrites it. // Check if we need to sync? For now, let's write it so 'readback' works. // memoryView.current.setFloat32(base + OFF_AXIS_TARGET_POS, axis.target, true); }); // --- 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) { // Legacy 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; } } } else if (rule.triggerType === LogicTriggerType.MEM_COMPARE) { // Memory Compare // Read 4 bytes at address as Float32 (Standardize on Float for M-registers for now) const addr = rule.triggerAddress || 0; if (addr >= 0 && addr < MEMORY_SIZE - 4) { const val = memoryView.current.getFloat32(addr, true); switch (rule.triggerCompareOp) { case LogicCondition.GREATER: conditionMet = val > rule.triggerValue; break; case LogicCondition.LESS: conditionMet = val < rule.triggerValue; break; case LogicCondition.EQUAL: conditionMet = Math.abs(val - rule.triggerValue) < 0.001; 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) { // Legacy Move: Directly set Axis Target axisUpdates.set(rule.targetAxisIndex, rule.targetAxisValue); // Also update Memory Map for consistency const base = ADDR_AXIS_BASE + (rule.targetAxisIndex * ADDR_AXIS_STRIDE); if (base < MEMORY_SIZE) memoryView.current.setFloat32(base + OFF_AXIS_TARGET_POS, rule.targetAxisValue, true); } else if (rule.actionType === LogicActionType.MEM_OPERATION) { // Memory Math const addr = rule.memAddress || 0; if (addr >= 0 && addr < MEMORY_SIZE - 4) { let currentVal = memoryView.current.getFloat32(addr, true); let newVal = currentVal; switch (rule.memOperation) { case LogicMathOp.SET: newVal = rule.memOperandValue; break; case LogicMathOp.ADD: newVal = currentVal + rule.memOperandValue; break; case LogicMathOp.SUB: newVal = currentVal - rule.memOperandValue; break; } memoryView.current.setFloat32(addr, newVal, true); } } } }); // Apply Logical Outputs const effectiveOutputs = logicOutputs.map((bit, i) => bit || manualOutputs[i]); setOutputs(effectiveOutputs); // --- 3. Memory Output Scan (Memory -> Axis Command) --- // Read Target Position from Memory for each axis and apply to State axes.forEach(axis => { const base = ADDR_AXIS_BASE + (axis.id * ADDR_AXIS_STRIDE); const memTarget = memoryView.current.getFloat32(base + OFF_AXIS_TARGET_POS, true); // Simple change detection (epsilon) or just overwrite if non-zero? // Issue: If UI Slider sets axis.target, and memory has 0, memory overwrites UI. // User Requirement: "write to memory... motor moves". // So Memory is the source of truth for Target. // But initially memory is 0. So Axis goes to 0? // Workaround: If memory target is exactly 0 and axis is not 0, maybe don't force? // Better: Initialize Memory with Axis Target on startup. // For now, let's assume Logic drives Memory. if (memTarget !== axis.target && Math.abs(memTarget - axis.target) > 0.001) { axisUpdates.set(axis.id, memTarget); } }); // Apply Axis Logic Updates 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' | 'memory'>('layout'); // --- Memory System (Ref-based, high frequency) --- const memoryBuffer = useRef(new ArrayBuffer(MEMORY_SIZE)); // 10000 bytes const memoryView = useRef(new DataView(memoryBuffer.current)); 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 */}
SIMP
v0.9.2
{/* 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} /> )} {/* Memory View */} {activeView === 'memory' && ( )}
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" }) => ( );