287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
|
|
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,
|
|
AxisData
|
|
} from '../types';
|
|
|
|
interface ObjectProps {
|
|
data: SimObject;
|
|
isSelected?: boolean;
|
|
isPlaying?: boolean;
|
|
onSelect?: (id: string) => void;
|
|
onUpdate?: (id: string, updates: Partial<SimObject>) => void;
|
|
onInteract?: (id: string) => void;
|
|
axes?: AxisData[];
|
|
}
|
|
|
|
// -- Helper Material --
|
|
const selectedMaterial = new THREE.MeshBasicMaterial({ color: '#4ade80', wireframe: true, transparent: true, opacity: 0.5 });
|
|
|
|
// -- Axis Visualizer Component --
|
|
const AxisVisualizer = ({ object, isPlaying, axes, isSelected }: { object: AxisObject, isPlaying: boolean, axes?: AxisData[], isSelected?: boolean }) => {
|
|
const carriageRef = useRef<THREE.Mesh>(null);
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
|
|
// 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 (
|
|
<group ref={groupRef}>
|
|
<Text position={[railLength / 2, 0.8, 0]} fontSize={0.3} color="white" anchorX="center" anchorY="bottom">
|
|
{object.name} ({axisValue.toFixed(1)})
|
|
</Text>
|
|
|
|
<mesh position={[railLength / 2, 0, 0]}>
|
|
<boxGeometry args={[railLength + 0.5, 0.2, 0.5]} />
|
|
<meshStandardMaterial color="#475569" />
|
|
</mesh>
|
|
|
|
<mesh ref={carriageRef} position={[safePos, 0.25, 0]}>
|
|
<boxGeometry args={[0.8, 0.3, 0.6]} />
|
|
<meshStandardMaterial color={(isSelected && !isPlaying) ? '#facc15' : '#3b82f6'} />
|
|
</mesh>
|
|
|
|
{isSelected && !isPlaying && (
|
|
<mesh position={[railLength / 2, 0, 0]}>
|
|
<boxGeometry args={[railLength + 0.6, 0.3, 0.6]} />
|
|
<primitive object={selectedMaterial} attach="material" />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
} else if (object.type === ObjectType.AXIS_ROTARY) {
|
|
const rotationAngle = (axisValue % 360) * (Math.PI / 180);
|
|
|
|
return (
|
|
<group ref={groupRef}>
|
|
<Text position={[0, 1.5, 0]} fontSize={0.3} color="white" anchorX="center" anchorY="bottom">
|
|
{object.name} ({axisValue.toFixed(0)}°)
|
|
</Text>
|
|
|
|
<mesh position={[0, 0.25, 0]}>
|
|
<cylinderGeometry args={[0.8, 0.8, 0.5, 32]} />
|
|
<meshStandardMaterial color="#475569" />
|
|
</mesh>
|
|
|
|
<group name="rotary-inner-group" rotation={[0, -rotationAngle, 0]} position={[0, 0.6, 0]}>
|
|
<mesh>
|
|
<cylinderGeometry args={[0.7, 0.7, 0.2, 32]} />
|
|
<meshStandardMaterial color={(isSelected && !isPlaying) ? '#facc15' : '#3b82f6'} />
|
|
</mesh>
|
|
<mesh position={[0.5, 0.15, 0]}>
|
|
<boxGeometry args={[0.3, 0.1, 0.1]} />
|
|
<meshStandardMaterial color="white" />
|
|
</mesh>
|
|
</group>
|
|
|
|
{isSelected && !isPlaying && (
|
|
<mesh position={[0, 0.4, 0]}>
|
|
<cylinderGeometry args={[0.9, 0.9, 1, 16]} />
|
|
<primitive object={selectedMaterial} attach="material" />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// -- Cylinder Visualizer Component --
|
|
const CylinderVisualizer = ({ object, isPlaying, isSelected }: { object: CylinderObject, isPlaying: boolean, isSelected?: boolean }) => {
|
|
const housingLen = 2;
|
|
const extension = Math.min(object.stroke, Math.max(0, object.currentPosition));
|
|
|
|
return (
|
|
<group>
|
|
<Text position={[housingLen / 2, 0.6, 0]} fontSize={0.3} color="white" anchorX="center" anchorY="bottom">
|
|
{object.name}
|
|
</Text>
|
|
|
|
<mesh rotation={[0, 0, -Math.PI / 2]} position={[housingLen / 2, 0, 0]}>
|
|
<cylinderGeometry args={[0.3, 0.3, housingLen, 16]} />
|
|
<meshStandardMaterial color="#64748b" opacity={0.8} transparent />
|
|
</mesh>
|
|
|
|
<mesh
|
|
rotation={[0, 0, -Math.PI / 2]}
|
|
position={[housingLen + (extension / 2), 0, 0]}
|
|
>
|
|
<cylinderGeometry args={[0.15, 0.15, extension + 0.2, 16]} />
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.8} roughness={0.2} />
|
|
</mesh>
|
|
<mesh position={[housingLen + extension, 0, 0]}>
|
|
<boxGeometry args={[0.2, 0.4, 0.4]} />
|
|
<meshStandardMaterial color="#475569" />
|
|
</mesh>
|
|
|
|
{isSelected && !isPlaying && (
|
|
<mesh position={[housingLen / 2 + extension / 2, 0, 0]}>
|
|
<boxGeometry args={[housingLen + extension + 0.5, 0.7, 0.7]} />
|
|
<primitive object={selectedMaterial} attach="material" />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// -- Switch Component --
|
|
export const Switch: React.FC<ObjectProps> = ({ data, isSelected, isPlaying, onSelect, onInteract }) => {
|
|
const sw = data as SwitchObject;
|
|
|
|
return (
|
|
<group
|
|
position={[sw.position.x, sw.position.y, sw.position.z]}
|
|
rotation={[sw.rotation.x, sw.rotation.y, sw.rotation.z]}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (!isPlaying) onSelect?.(sw.id);
|
|
if (onInteract) onInteract(sw.id);
|
|
}}
|
|
>
|
|
<Text position={[0, 0.6, 0]} fontSize={0.25} color="white" anchorX="center" anchorY="bottom">
|
|
{sw.name}
|
|
</Text>
|
|
|
|
<mesh position={[0, 0.1, 0]}>
|
|
<boxGeometry args={[0.6, 0.2, 0.6]} />
|
|
<meshStandardMaterial color="#334155" />
|
|
</mesh>
|
|
|
|
<mesh position={[0, 0.2 + (sw.isOn ? -0.05 : 0), 0]}>
|
|
<cylinderGeometry args={[0.2, 0.2, 0.2, 16]} />
|
|
<meshStandardMaterial color={sw.isOn ? '#ef4444' : '#94a3b8'} />
|
|
</mesh>
|
|
|
|
{isSelected && !isPlaying && (
|
|
<mesh position={[0, 0.15, 0]}>
|
|
<boxGeometry args={[0.7, 0.5, 0.7]} />
|
|
<primitive object={selectedMaterial} attach="material" />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// -- LED Component --
|
|
export const Led: React.FC<ObjectProps> = ({ data, isSelected, isPlaying, onSelect }) => {
|
|
const led = data as LedObject;
|
|
|
|
return (
|
|
<group
|
|
position={[led.position.x, led.position.y, led.position.z]}
|
|
rotation={[led.rotation.x, led.rotation.y, led.rotation.z]}
|
|
onClick={(e) => { e.stopPropagation(); if (!isPlaying) onSelect?.(led.id); }}
|
|
>
|
|
<Text position={[0, 0.6, 0]} fontSize={0.25} color="white" anchorX="center" anchorY="bottom">
|
|
{led.name}
|
|
</Text>
|
|
|
|
<mesh position={[0, 0.1, 0]}>
|
|
<cylinderGeometry args={[0.2, 0.25, 0.2, 16]} />
|
|
<meshStandardMaterial color="#334155" />
|
|
</mesh>
|
|
|
|
<mesh position={[0, 0.3, 0]}>
|
|
<sphereGeometry args={[0.2, 16, 16]} />
|
|
<meshStandardMaterial
|
|
color={led.isOn ? led.color : '#334155'}
|
|
emissive={led.isOn ? led.color : '#000000'}
|
|
emissiveIntensity={led.isOn ? 2 : 0}
|
|
/>
|
|
</mesh>
|
|
|
|
{isSelected && !isPlaying && (
|
|
<mesh position={[0, 0.2, 0]}>
|
|
<boxGeometry args={[0.6, 0.6, 0.6]} />
|
|
<primitive object={selectedMaterial} attach="material" />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
export const EditableObject: React.FC<ObjectProps & { enableTransform?: boolean, snap?: number | null }> = (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, {
|
|
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 }
|
|
});
|
|
}
|
|
};
|
|
|
|
let ComponentToRender;
|
|
switch (data.type) {
|
|
case ObjectType.AXIS_LINEAR:
|
|
case ObjectType.AXIS_ROTARY:
|
|
ComponentToRender = <AxisVisualizer object={data as AxisObject} isPlaying={isPlaying!} axes={axes} isSelected={props.isSelected} />;
|
|
break;
|
|
case ObjectType.CYLINDER:
|
|
ComponentToRender = <CylinderVisualizer object={data as CylinderObject} isPlaying={isPlaying!} isSelected={props.isSelected} />;
|
|
break;
|
|
case ObjectType.SWITCH:
|
|
ComponentToRender = <Switch {...props} />;
|
|
break;
|
|
case ObjectType.LED:
|
|
ComponentToRender = <Led {...props} />;
|
|
break;
|
|
default:
|
|
ComponentToRender = null;
|
|
}
|
|
|
|
return (
|
|
<group
|
|
position={[data.position.x, data.position.y, data.position.z]}
|
|
rotation={[data.rotation.x, data.rotation.y, data.rotation.z]}
|
|
onClick={(e) => { e.stopPropagation(); if (!isPlaying) props.onSelect?.(data.id); }}
|
|
>
|
|
{ComponentToRender}
|
|
{props.isSelected && enableTransform && !isPlaying && (
|
|
<TransformControls
|
|
object={undefined} // TransformControls will apply to its children, not a specific object prop
|
|
position={[0, 0, 0]} // Position relative to the group
|
|
rotation={[0, 0, 0]} // Rotation relative to the group
|
|
onMouseUp={handleTransformChange}
|
|
size={0.6}
|
|
mode="translate"
|
|
translationSnap={snap ?? undefined}
|
|
rotationSnap={snap ? Math.PI / 12 : undefined}
|
|
/>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|