diff --git a/App.tsx b/App.tsx index 246976a..241c97d 100644 --- a/App.tsx +++ b/App.tsx @@ -6,12 +6,13 @@ import { OrbitControls, Grid, GizmoHelper, GizmoViewcube } from '@react-three/dr 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 + 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 } from 'lucide-react'; +import { Layout as LayoutIcon, Cpu, Play, Pause, Square, Download, Upload, Magnet, Settings } from 'lucide-react'; // --- Simulation Manager (PLC Scan Cycle) --- const SimulationLoop = ({ @@ -22,7 +23,9 @@ const SimulationLoop = ({ manualInputs, manualOutputs, setInputs, - setOutputs + setOutputs, + axes, + setAxes }: { isPlaying: boolean, objects: SimObject[], @@ -31,25 +34,30 @@ const SimulationLoop = ({ manualInputs: boolean[], manualOutputs: boolean[], setInputs: (inputs: boolean[]) => void, - setOutputs: (outputs: 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(16).fill(false); + 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 < 16) sensorInputs[port] = true; + if (port >= 0 && port < 32) 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 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; + }); + } } }); @@ -57,20 +65,57 @@ const SimulationLoop = ({ setInputs(effectiveInputs); // --- 2. PLC Logic Execution --- - const logicOutputs = new Array(16).fill(false); + 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; - 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; + + // 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); + } } }); - // Effective Outputs = Logic OR Manual Force + // 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; @@ -101,17 +146,8 @@ const SimulationLoop = ({ } } 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; - } + // 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; @@ -119,6 +155,23 @@ const SimulationLoop = ({ }); 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; @@ -129,18 +182,31 @@ export default function App() { 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 [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(16).fill('')); - const [outputNames, setOutputNames] = useState(new Array(16).fill('')); + 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; @@ -194,19 +260,20 @@ export default function App() { const handleReset = () => { setIsPlaying(false); - setManualInputs(new Array(16).fill(false)); - setManualOutputs(new Array(16).fill(false)); + setManualInputs(new Array(32).fill(false)); + setManualOutputs(new Array(32).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 }; + // 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.0", objects, logicRules, inputNames, outputNames }; + 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'); @@ -225,6 +292,7 @@ export default function App() { 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); @@ -237,10 +305,10 @@ export default function App() { 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; + 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, currentValue: 0, targetValue: 0, speed: 1, isOscillating: false, triggers: [] } as AxisObject; + 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; @@ -274,18 +342,27 @@ export default function App() { onClick={() => setActiveView('layout')} className={`flex items-center gap-2 px-6 py-2 rounded-lg text-sm font-bold transition-all ${activeView === 'layout' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:text-gray-300'}`} > - Layout + Layout
+ {/* 4. Top Navigation에 Setup 버튼 추가 */} + +
@@ -349,6 +426,8 @@ export default function App() { outputNames={outputNames} onToggleInput={handleToggleManualInput} onToggleOutput={handleToggleManualOutput} + axes={axes} + setAxes={setAxes} /> {/* Center/Right: Dynamic Content based on tab */} @@ -399,6 +478,7 @@ export default function App() { onSelect={setSelectedId} onUpdate={(id, updates) => 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} /> ))} @@ -411,6 +491,8 @@ export default function App() { manualOutputs={manualOutputs} setInputs={setInputs} setOutputs={setOutputs} + axes={axes} + setAxes={setAxes} /> @@ -421,9 +503,11 @@ export default function App() { isPlaying={isPlaying} inputNames={inputNames} outputNames={outputNames} + axes={axes} onAddObject={handleAddObject} onDeleteObject={(id) => { 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} /> @@ -438,9 +522,20 @@ export default function App() { onAddRule={(r) => 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))} + /> ); } diff --git a/components/IOPanel.tsx b/components/IOPanel.tsx index a78060a..ee4d1dd 100644 --- a/components/IOPanel.tsx +++ b/components/IOPanel.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { AxisData } from '../types'; interface IOPanelProps { inputs: boolean[]; @@ -7,21 +8,25 @@ interface IOPanelProps { outputNames: string[]; onToggleInput: (index: number) => void; onToggleOutput: (index: number) => void; + axes: AxisData[]; + setAxes: React.Dispatch>; } -export const IOPanel: React.FC = ({ - inputs, - outputs, - inputNames, +export const IOPanel: React.FC = ({ + inputs, + outputs, + inputNames, outputNames, onToggleInput, - onToggleOutput + onToggleOutput, + axes, + setAxes }) => { const renderBits = ( - bits: boolean[], - names: string[], - colorClass: string, - label: string, + bits: boolean[], + names: string[], + colorClass: string, + label: string, onToggle: (i: number) => void ) => (
@@ -29,14 +34,13 @@ export const IOPanel: React.FC = ({
{bits.map((isActive, i) => (
- @@ -56,7 +60,7 @@ export const IOPanel: React.FC = ({
I/O Monitor - + {renderBits(inputs, inputNames, 'bg-green-500', 'Inputs', onToggleInput)}
{renderBits(outputs, outputNames, 'bg-red-500', 'Outputs', onToggleOutput)} @@ -66,6 +70,33 @@ export const IOPanel: React.FC = ({

• Click bits to toggle state

• Hover to see port names

+ +
+

Axis Channels

+
+ {axes.map((axis, i) => ( +
+
+ CH{i} + {axis.name} + {axis.value.toFixed(1)} +
+ {/* Miniature Slider for quick view/control */} + { + const val = parseFloat(e.target.value); + setAxes(prev => prev.map((a, idx) => idx === i ? { ...a, value: val, target: val } : a)); + }} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+ ))} +
+
); }; diff --git a/components/LadderEditor.tsx b/components/LadderEditor.tsx index 5dd9a1a..6d848ad 100644 --- a/components/LadderEditor.tsx +++ b/components/LadderEditor.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Trash2, PlusCircle, Power } from 'lucide-react'; -import { IOLogicRule, LogicCondition, LogicAction } from '../types'; +import { Trash2, PlusCircle, Power, Zap, Gauge, Move } from 'lucide-react'; +import { IOLogicRule, LogicCondition, LogicAction, LogicTriggerType, LogicActionType, AxisData } from '../types'; interface LadderEditorProps { logicRules: IOLogicRule[]; @@ -12,6 +12,7 @@ interface LadderEditorProps { onAddRule: (rule: IOLogicRule) => void; onDeleteRule: (id: string) => void; onUpdateRule: (id: string, updates: Partial) => void; + axes: AxisData[]; } export const LadderEditor: React.FC = ({ @@ -22,29 +23,36 @@ export const LadderEditor: React.FC = ({ currentOutputs, onAddRule, onDeleteRule, - onUpdateRule + onUpdateRule, + axes }) => { return (
{/* Background Grid Pattern */} -
{/* Toolbar */}

Main Controller (Ladder Logic)

-
@@ -57,69 +65,146 @@ export const LadderEditor: React.FC = ({
{logicRules.map((rule, index) => { - const inputActive = currentInputs[rule.inputPort]; - const outputActive = currentOutputs[rule.outputPort]; - + const isInputTrigger = rule.triggerType === LogicTriggerType.INPUT_BIT; + const isAxisTrigger = rule.triggerType === LogicTriggerType.AXIS_COMPARE; + const isOutputAction = rule.actionType === LogicActionType.OUTPUT_COIL; + const isAxisAction = rule.actionType === LogicActionType.AXIS_MOVE; + + const inputActive = isInputTrigger && currentInputs[rule.inputPort]; + const outputActive = isOutputAction && currentOutputs[rule.outputPort]; + return ( -
+
{/* Rung Number */} -
{(index + 1).toString().padStart(3, '0')}
- +
{(index + 1).toString().padStart(3, '0')}
+ {/* Delete Button */} - - {/* The Rung Wire */} -
- {/* Energized segments */} - {inputActive &&
} - {outputActive &&
} +
- {/* Contact (Input) */} -
-
- {inputNames[rule.inputPort] || `Input ${rule.inputPort}`} + {/* LEFT: TRIGGER */} +
+
+ +
-
- {/* Terminal Brackets */} -
-
- - {/* Selector Area */} - + +
+ {isInputTrigger ? ( +
+ {inputNames[rule.inputPort] || `Input ${rule.inputPort}`} +
+ +
+
+ ) : ( +
+ + + onUpdateRule(rule.id, { triggerValue: parseFloat(e.target.value) })} + /> +
+ )}
- {/* Coil (Output) */} -
-
- {outputNames[rule.outputPort] || `Output ${rule.outputPort}`} + {/* ARROW */} +
+ + {/* RIGHT: ACTION */} +
+
+ +
-
- {/* Coil Brackets (Parens style) */} -
-
-
- - + +
+ {isOutputAction ? ( +
+ {outputNames[rule.outputPort] || `Output ${rule.outputPort}`} +
+ +
+
+ ) : ( +
+ MOVE + + TO + onUpdateRule(rule.id, { targetAxisValue: parseFloat(e.target.value) })} + /> +
+ )}
+
); diff --git a/components/SceneObjects.tsx b/components/SceneObjects.tsx index f3b6943..9cb559d 100644 --- a/components/SceneObjects.tsx +++ b/components/SceneObjects.tsx @@ -2,119 +2,129 @@ import React, { useRef } from 'react'; import { TransformControls, Text } from '@react-three/drei'; import * as THREE from 'three'; +import { useFrame } from '@react-three/fiber'; import { SimObject, ObjectType, AxisObject, CylinderObject, LedObject, - SwitchObject + SwitchObject, + AxisData } from '../types'; interface ObjectProps { data: SimObject; - isSelected: boolean; + isSelected?: boolean; isPlaying?: boolean; - onSelect: (id: string) => void; - onUpdate: (id: string, updates: Partial) => void; + onSelect?: (id: string) => void; + onUpdate?: (id: string, updates: Partial) => void; onInteract?: (id: string) => void; + axes?: AxisData[]; } // -- Helper Material -- const selectedMaterial = new THREE.MeshBasicMaterial({ color: '#4ade80', wireframe: true, transparent: true, opacity: 0.5 }); -// -- Linear Axis Component -- -export const LinearAxis: React.FC = ({ data, isSelected, isPlaying, onSelect }) => { - const axis = data as AxisObject; - const railLength = 5; - const normalizedPos = ((axis.currentValue - axis.min) / (axis.max - axis.min)) * railLength; - const safePos = isNaN(normalizedPos) ? 0 : Math.max(0, Math.min(railLength, normalizedPos)); +// -- Axis Visualizer Component -- +const AxisVisualizer = ({ object, isPlaying, axes, isSelected }: { object: AxisObject, isPlaying: boolean, axes?: AxisData[], isSelected?: boolean }) => { + const carriageRef = useRef(null); + const groupRef = useRef(null); - return ( - { e.stopPropagation(); if (!isPlaying) onSelect(axis.id); }} - > - - {axis.name} ({axis.currentValue.toFixed(1)}) - + // Resolve current value from Global Axis Channel + const axisValue = axes && object.axisIndex !== undefined && axes[object.axisIndex] ? axes[object.axisIndex].value : 0; // Fallback to 0 if axes not provided or index invalid - - - - + useFrame(() => { + if (object.type === ObjectType.AXIS_LINEAR && carriageRef.current) { + const railLength = 5; + const normalizedPos = ((axisValue - object.min) / (object.max - object.min)) * railLength; + const safePos = isNaN(normalizedPos) ? 0 : Math.max(0, Math.min(railLength, normalizedPos)); + carriageRef.current.position.x = safePos; + } else if (object.type === ObjectType.AXIS_ROTARY && groupRef.current) { + const rotationAngle = (axisValue % 360) * (Math.PI / 180); + // The inner group for rotary axis is rotated, not the carriage directly + const innerRotaryGroup = groupRef.current.children.find(child => child.name === 'rotary-inner-group'); + if (innerRotaryGroup) { + innerRotaryGroup.rotation.y = -rotationAngle; + } + } + }); - - - - + if (object.type === ObjectType.AXIS_LINEAR) { + const railLength = 5; + const normalizedPos = ((axisValue - object.min) / (object.max - object.min)) * railLength; + const safePos = isNaN(normalizedPos) ? 0 : Math.max(0, Math.min(railLength, normalizedPos)); + + return ( + + + {object.name} ({axisValue.toFixed(1)}) + - {isSelected && !isPlaying && ( - - + + - )} - - ); -}; -// -- Rotary Axis Component -- -export const RotaryAxis: React.FC = ({ data, isSelected, isPlaying, onSelect }) => { - const axis = data as AxisObject; - const rotationAngle = (axis.currentValue % 360) * (Math.PI / 180); - - return ( - { e.stopPropagation(); if (!isPlaying) onSelect(axis.id); }} - > - - {axis.name} ({axis.currentValue.toFixed(0)}°) - - - - - - - - - - + + - - - - - - {isSelected && !isPlaying && ( - - - + {isSelected && !isPlaying && ( + + + + + )} + + ); + } else if (object.type === ObjectType.AXIS_ROTARY) { + const rotationAngle = (axisValue % 360) * (Math.PI / 180); + + return ( + + + {object.name} ({axisValue.toFixed(0)}°) + + + + + - )} - - ); + + + + + + + + + + + + + {isSelected && !isPlaying && ( + + + + + )} + + ); + } + return null; }; -// -- Cylinder Component -- -export const Cylinder: React.FC = ({ data, isSelected, isPlaying, onSelect }) => { - const cyl = data as CylinderObject; +// -- Cylinder Visualizer Component -- +const CylinderVisualizer = ({ object, isPlaying, isSelected }: { object: CylinderObject, isPlaying: boolean, isSelected?: boolean }) => { const housingLen = 2; - const extension = Math.min(cyl.stroke, Math.max(0, cyl.currentPosition)); + const extension = Math.min(object.stroke, Math.max(0, object.currentPosition)); return ( - { e.stopPropagation(); if (!isPlaying) onSelect(cyl.id); }} - > + - {cyl.name} + {object.name} @@ -154,7 +164,7 @@ export const Switch: React.FC = ({ data, isSelected, isPlaying, onS rotation={[sw.rotation.x, sw.rotation.y, sw.rotation.z]} onClick={(e) => { e.stopPropagation(); - if (!isPlaying) onSelect(sw.id); + if (!isPlaying) onSelect?.(sw.id); if (onInteract) onInteract(sw.id); }} > @@ -190,7 +200,7 @@ export const Led: React.FC = ({ data, isSelected, isPlaying, onSele { e.stopPropagation(); if (!isPlaying) onSelect(led.id); }} + onClick={(e) => { e.stopPropagation(); if (!isPlaying) onSelect?.(led.id); }} > {led.name} @@ -220,34 +230,50 @@ export const Led: React.FC = ({ data, isSelected, isPlaying, onSele ); }; -export const EditableObject: React.FC = (props) => { - const { data, enableTransform, isPlaying, onUpdate, snap } = props; +export const EditableObject: React.FC = (props) => { + const { data, enableTransform, isPlaying, onUpdate, snap, axes } = props; const handleTransformChange = (e: any) => { if (e.target.object) { const o = e.target.object; - onUpdate(data.id, { + onUpdate?.(data.id, { position: { x: o.position.x, y: o.position.y, z: o.position.z }, rotation: { x: o.rotation.x, y: o.rotation.y, z: o.rotation.z } }); } }; - const Component = - data.type === ObjectType.AXIS_LINEAR ? LinearAxis : - data.type === ObjectType.AXIS_ROTARY ? RotaryAxis : - data.type === ObjectType.CYLINDER ? Cylinder : - data.type === ObjectType.SWITCH ? Switch : - Led; + let ComponentToRender; + switch (data.type) { + case ObjectType.AXIS_LINEAR: + case ObjectType.AXIS_ROTARY: + ComponentToRender = ; + break; + case ObjectType.CYLINDER: + ComponentToRender = ; + break; + case ObjectType.SWITCH: + ComponentToRender = ; + break; + case ObjectType.LED: + ComponentToRender = ; + break; + default: + ComponentToRender = null; + } return ( - <> - + { e.stopPropagation(); if (!isPlaying) props.onSelect?.(data.id); }} + > + {ComponentToRender} {props.isSelected && enableTransform && !isPlaying && ( )} - + ); }; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index bc0a174..0b51018 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { } from 'lucide-react'; import { SimObject, ObjectType, AxisObject, - CylinderObject, LedObject, SwitchObject + CylinderObject, LedObject, SwitchObject, AxisData } from '../types'; interface SidebarProps { @@ -16,10 +16,12 @@ interface SidebarProps { isPlaying: boolean; inputNames: string[]; outputNames: string[]; + axes: AxisData[]; onAddObject: (type: ObjectType) => void; onDeleteObject: (id: string) => void; onUpdateObject: (id: string, updates: Partial) => void; - onSelect: (id: string | null) => void; + onUpdateAxis: (index: number, updates: Partial) => void; + onSelect: React.Dispatch>; onUpdatePortName: (type: 'input' | 'output', index: number, name: string) => void; } @@ -29,12 +31,14 @@ export const Sidebar: React.FC = ({ isPlaying, inputNames, outputNames, + axes, onAddObject, onDeleteObject, onUpdateObject, - onUpdatePortName + onUpdateAxis, + onSelect, + onUpdatePortName, }) => { - const [activeTab, setActiveTab] = useState<'properties' | 'system'>('properties'); const selectedObject = objects.find(o => o.id === selectedId); const renderInput = (label: string, value: any, onChange: (val: any) => void, type = "text", step?: number) => ( @@ -56,156 +60,123 @@ export const Sidebar: React.FC = ({ return (
- {/* Sub-navigation */} + {/* Header */}

Layout Config

-
- - -
- {activeTab === 'properties' && ( - <> - {/* Tool Box */} -
- } label="Lin Axis" onClick={() => onAddObject(ObjectType.AXIS_LINEAR)} /> - } label="Rot Axis" onClick={() => onAddObject(ObjectType.AXIS_ROTARY)} /> - } label="Cylinder" onClick={() => onAddObject(ObjectType.CYLINDER)} /> - } label="Switch" onClick={() => onAddObject(ObjectType.SWITCH)} /> - } label="LED" onClick={() => onAddObject(ObjectType.LED)} /> + {/* Tool Box */} +
+ } label="Lin Axis" onClick={() => onAddObject(ObjectType.AXIS_LINEAR)} /> + } label="Rot Axis" onClick={() => onAddObject(ObjectType.AXIS_ROTARY)} /> + } label="Cylinder" onClick={() => onAddObject(ObjectType.CYLINDER)} /> + } label="Switch" onClick={() => onAddObject(ObjectType.SWITCH)} /> + } label="LED" onClick={() => onAddObject(ObjectType.LED)} /> +
+ + {/* Properties Form */} +
+ {!selectedObject ? ( +
+ +

Select an object
to edit properties

+ ) : ( +
+
+

Component Detail

+ +
- {/* Properties Form */} -
- {!selectedObject ? ( -
- -

Select an object
to edit properties

-
- ) : ( -
-
-

Component Detail

- -
+
+ {renderInput("Object Name", selectedObject.name, (val) => onUpdateObject(selectedObject.id, { name: val }))} -
- {renderInput("Object Name", selectedObject.name, (val) => onUpdateObject(selectedObject.id, { name: val }))} + {selectedObject.type === ObjectType.SWITCH && ( + <> +
+ + +
+
+ onUpdateObject(selectedObject.id, { isMomentary: e.target.checked })} /> + Momentary Contact +
+ + )} - {selectedObject.type === ObjectType.SWITCH && ( - <> -
- - -
-
- onUpdateObject(selectedObject.id, { isMomentary: e.target.checked })} /> - Momentary Contact -
- - )} + {selectedObject.type === ObjectType.LED && ( + <> +
+ + +
+ {renderInput("Light Color", (selectedObject as LedObject).color, (v) => onUpdateObject(selectedObject.id, { color: v }), "color")} + + )} - {selectedObject.type === ObjectType.LED && ( - <> -
- - -
- {renderInput("Light Color", (selectedObject as LedObject).color, (v) => onUpdateObject(selectedObject.id, { color: v }), "color")} - - )} + {(selectedObject.type === ObjectType.AXIS_LINEAR || selectedObject.type === ObjectType.AXIS_ROTARY) && ( +
+ + - {(selectedObject.type === ObjectType.AXIS_LINEAR || selectedObject.type === ObjectType.AXIS_ROTARY) && ( -
- - onUpdateObject(selectedObject.id, { currentValue: parseFloat(e.target.value), targetValue: parseFloat(e.target.value) })} - className="w-full h-1.5 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-blue-500" /> + {/* Axis Control Slider in Sidebar */} +
+
+ Manual Jog + + {axes[(selectedObject as AxisObject).axisIndex]?.value.toFixed(1) || '0.0'} +
- )} + { + const val = parseFloat(e.target.value); + const idx = (selectedObject as AxisObject).axisIndex; + if (axes[idx]) { + onUpdateAxis(idx, { value: val, target: val }); + } + }} + className="w-full h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
-
- )} -
- - )} - - {activeTab === 'system' && ( -
-
-

Hardware Mapping

- -
-
-

-
Inputs (X0 - X15) -

-
- {inputNames.map((name, i) => ( -
- X{i.toString().padStart(2, '0')} - onUpdatePortName('input', i, e.target.value)} - /> -
- ))} -
-
- -
-

-
Outputs (Y0 - Y15) -

-
- {outputNames.map((name, i) => ( -
- Y{i.toString().padStart(2, '0')} - onUpdatePortName('output', i, e.target.value)} - /> -
- ))} -
-
+ )}
-
- )} + )} +
); }; diff --git a/components/SystemSetupDialog.tsx b/components/SystemSetupDialog.tsx new file mode 100644 index 0000000..b6a6f87 --- /dev/null +++ b/components/SystemSetupDialog.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { X } from 'lucide-react'; + +import { AxisData } from '../types'; + +interface SystemSetupDialogProps { + isOpen: boolean; + onClose: () => void; + inputNames: string[]; + outputNames: string[]; + axes: AxisData[]; + onUpdatePortName: (type: 'input' | 'output', index: number, name: string) => void; + onUpdateAxis: (index: number, updates: Partial) => void; +} + +export const SystemSetupDialog: React.FC = ({ + isOpen, + onClose, + inputNames, + outputNames, + onUpdatePortName, + axes, + onUpdateAxis +}) => { + const [activeTab, setActiveTab] = React.useState<'io' | 'axes'>('io'); + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header */} +
+
+
+

System Setup

+

Configure Hardware & Drives

+
+ +
+ + +
+
+ + +
+ + {/* Content */} +
+ + {activeTab === 'io' && ( +
+ + {/* Input Configuration */} +
+

+
+ Input Mapping (X00 - X31) +

+ +
+ {inputNames.map((name, i) => ( +
+ + X{i.toString().padStart(2, '0')} + + onUpdatePortName('input', i, e.target.value)} + /> +
+ ))} +
+
+ + {/* Output Configuration */} +
+

+
+ Output Mapping (Y00 - Y31) +

+ +
+ {outputNames.map((name, i) => ( +
+ + Y{i.toString().padStart(2, '0')} + + onUpdatePortName('output', i, e.target.value)} + /> +
+ ))} +
+
+
+ )} + + {activeTab === 'axes' && ( +
+

+
+ Global Axis Configuration (8 Channels) +

+ +
+ {axes.map((axis, i) => ( +
+
+ + onUpdateAxis(i, { name: e.target.value })} + className="bg-gray-900 border border-gray-800 rounded px-2 py-1 text-sm text-white focus:border-blue-500 outline-none" + /> +
+ +
+
+ + + {axis.value.toFixed(1)} {axis.type === 'rotary' ? 'deg' : 'mm'} + +
+ { + const val = parseFloat(e.target.value); + onUpdateAxis(i, { value: val, target: val }); + }} + className="w-full h-2 bg-gray-800 rounded-lg appearance-none cursor-pointer accent-blue-600 hover:accent-blue-500" + /> +
+ +
+ + +
+
+ ))} +
+
+ )} + +
+ + {/* Footer */} +
+ +
+ +
+
+ ); +}; diff --git a/types.ts b/types.ts index 391f5d4..75a03c3 100644 --- a/types.ts +++ b/types.ts @@ -27,22 +27,28 @@ export interface AxisPositionTrigger { targetInputPort: number; // Sets this Input bit when condition met } +export interface AxisData { + id: number; // 0-7 + name: string; + value: number; + target: number; + speed: number; + type: 'linear' | 'rotary'; +} + export interface AxisObject extends BaseObject { type: ObjectType.AXIS_LINEAR | ObjectType.AXIS_ROTARY; + axisIndex: number; // 0-7, Binds to Global Axis min: number; max: number; - currentValue: number; - targetValue: number; - speed: number; - isOscillating: boolean; - triggers: AxisPositionTrigger[]; // Axis drives Inputs + triggers: AxisPositionTrigger[]; // Triggers still belong to the physical object placement } export interface CylinderObject extends BaseObject { type: ObjectType.CYLINDER; stroke: number; - extended: boolean; - currentPosition: number; + extended: boolean; + currentPosition: number; speed: number; outputPort: number; // Reads this Output bit to extend } @@ -64,24 +70,51 @@ export interface LedObject extends BaseObject { export type SimObject = AxisObject | CylinderObject | SwitchObject | LedObject; // Logic Types +export enum LogicTriggerType { + INPUT_BIT = 'INPUT_BIT', + AXIS_COMPARE = 'AXIS_COMPARE', +} + +export enum LogicActionType { + OUTPUT_COIL = 'OUTPUT_COIL', + AXIS_MOVE = 'AXIS_MOVE', +} + export enum LogicCondition { IS_ON = 'IS_ON', IS_OFF = 'IS_OFF', + GREATER = '>', + LESS = '<', + EQUAL = '==', + GREATER_EQUAL = '>=', + LESS_EQUAL = '<=', } export enum LogicAction { - SET_ON = 'ON', - SET_OFF = 'OFF', - TOGGLE = 'TOGGLE', + SET_ON = 'ON', + SET_OFF = 'OFF', + TOGGLE = 'TOGGLE', + MOVE_ABS = 'MOVE_ABS', + MOVE_REL = 'MOVE_REL', } export interface IOLogicRule { id: string; - inputPort: number; - condition: LogicCondition; - outputPort: number; - action: LogicAction; enabled: boolean; + + // Trigger + triggerType: LogicTriggerType; + inputPort: number; // For INPUT_BIT + triggerAxisIndex: number; // For AXIS_COMPARE + triggerCompareOp: LogicCondition; // For AXIS_COMPARE (>, <, ==) + triggerValue: number; // For AXIS_COMPARE + + // Action + actionType: LogicActionType; + outputPort: number; // For OUTPUT_COIL + action: LogicAction; // For OUTPUT_COIL (ON/OFF) + targetAxisIndex: number; // For AXIS_MOVE + targetAxisValue: number; // For AXIS_MOVE } // Full Project Export Type @@ -91,4 +124,5 @@ export interface ProjectData { logicRules: IOLogicRule[]; inputNames: string[]; outputNames: string[]; + axes: AxisData[]; }