1700 lines
76 KiB
TypeScript
1700 lines
76 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { SimulationMap, AgvState, ToolType, MapNode, MapEdge, MagnetLine, AgvMotionState, NodeType, FloorMark } from '../types';
|
|
import { AGV_WIDTH, AGV_HEIGHT, SENSOR_OFFSET_FRONT, SENSOR_OFFSET_REAR, SENSOR_OFFSET_MARK, MAX_SPEED, TURN_SPEED, MARK_SEARCH_SPEED, SPEED_L, SPEED_M, SPEED_H, MAGNET_WIDTH, MAGNET_COLOR, GRID_SIZE } from '../constants';
|
|
import { Trash2, X, Move } from 'lucide-react';
|
|
|
|
interface SimulationCanvasProps {
|
|
activeTool: ToolType;
|
|
mapData: SimulationMap;
|
|
setMapData: (data: SimulationMap | ((prev: SimulationMap) => SimulationMap)) => void;
|
|
agvState: AgvState;
|
|
setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void;
|
|
onLog: (msg: string) => void;
|
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
|
}
|
|
|
|
interface HitObject {
|
|
type: 'NODE' | 'EDGE' | 'MAGNET' | 'MARK';
|
|
id: string;
|
|
name: string; // For display
|
|
dist: number;
|
|
}
|
|
|
|
const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog, onSelectionChange }) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
|
|
|
// --- View Transform (Zoom/Pan) ---
|
|
const viewRef = useRef({ x: 0, y: 0, scale: 1 });
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const isSpacePressedRef = useRef(false);
|
|
|
|
// Interaction State
|
|
const [dragStartPos, setDragStartPos] = useState<{ x: number, y: number } | null>(null);
|
|
const [startNodeId, setStartNodeId] = useState<string | null>(null);
|
|
|
|
// Selection & Editing State
|
|
// Changed: Support multiple items
|
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
|
// dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks
|
|
const [dragHandle, setDragHandle] = useState<string | null>(null);
|
|
|
|
// Box Selection State
|
|
const [selectionBox, setSelectionBox] = useState<{ start: { x: number, y: number }, current: { x: number, y: number } } | null>(null);
|
|
|
|
// Notify parent of selection change
|
|
useEffect(() => {
|
|
if (onSelectionChange) {
|
|
onSelectionChange(selectedItemIds);
|
|
}
|
|
}, [selectedItemIds, onSelectionChange]);
|
|
|
|
// Move/Drag State for Select Tool (Whole Object)
|
|
const [dragTarget, setDragTarget] = useState<{
|
|
type: 'MULTI' | 'AGV';
|
|
startMouse: { x: number, y: number };
|
|
// For MULTI: store map of {id: initialState}
|
|
initialStates?: Record<string, { x: number, y: number, p1?: any, p2?: any, controlPoint?: any }>;
|
|
// For AGV single move
|
|
initialAgvState?: { x: number, y: number };
|
|
} | null>(null);
|
|
|
|
// Curve Drawing State
|
|
const [curvePhase, setCurvePhase] = useState<0 | 1 | 2>(0);
|
|
const [tempMagnet, setTempMagnet] = useState<Partial<MagnetLine> | null>(null);
|
|
|
|
// Eraser Context Menu State
|
|
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: HitObject[] } | null>(null);
|
|
|
|
// Image Cache
|
|
const imageCacheRef = useRef<Record<string, HTMLImageElement>>({});
|
|
|
|
// Use ref for mouse position (Screen Coords)
|
|
const mouseScreenPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
|
|
// Use ref for mouse position (World Coords)
|
|
const mouseWorldPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
|
|
|
|
// Refs for loop access
|
|
const mapDataRef = useRef(mapData);
|
|
const agvStateRef = useRef(agvState);
|
|
|
|
// Track previous sensor position for intersection checking
|
|
const prevMarkSensorPosRef = useRef<{ x: number, y: number } | null>(null);
|
|
// Track the ID of the magnet currently being followed to prevent jumping
|
|
const lastMagnetIdRef = useRef<string | null>(null);
|
|
// Throttle log
|
|
const logThrottleRef = useRef(0);
|
|
|
|
// Sync refs
|
|
useEffect(() => { mapDataRef.current = mapData; }, [mapData]);
|
|
useEffect(() => { agvStateRef.current = agvState; }, [agvState]);
|
|
|
|
// Handle Resize
|
|
useEffect(() => {
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
setDimensions({
|
|
width: containerRef.current.clientWidth,
|
|
height: containerRef.current.clientHeight
|
|
});
|
|
}
|
|
};
|
|
|
|
updateSize();
|
|
const observer = new ResizeObserver(updateSize);
|
|
if (containerRef.current) {
|
|
observer.observe(containerRef.current);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// Keyboard Event for Spacebar Panning
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.code === 'Space') isSpacePressedRef.current = true;
|
|
if (e.key === 'Escape') {
|
|
setSelectedItemIds(new Set());
|
|
setDragHandle(null);
|
|
setDragTarget(null);
|
|
setContextMenu(null);
|
|
setSelectionBox(null);
|
|
}
|
|
};
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.code === 'Space') isSpacePressedRef.current = false;
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('keyup', handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('keyup', handleKeyUp);
|
|
};
|
|
}, []);
|
|
|
|
// Reset tool states when tool changes
|
|
useEffect(() => {
|
|
setCurvePhase(0);
|
|
setTempMagnet(null);
|
|
setDragStartPos(null);
|
|
setStartNodeId(null);
|
|
setContextMenu(null);
|
|
setDragTarget(null);
|
|
setDragHandle(null);
|
|
// Note: We might want to keep selection when switching tools, but often resetting is safer
|
|
if (activeTool !== ToolType.SELECT) setSelectedItemIds(new Set());
|
|
}, [activeTool]);
|
|
|
|
// Preload Images
|
|
useEffect(() => {
|
|
const cache = imageCacheRef.current;
|
|
mapData.nodes.forEach(node => {
|
|
if (node.type === NodeType.Image && node.imageBase64 && !cache[node.id]) {
|
|
const img = new Image();
|
|
let src = node.imageBase64;
|
|
if (!src.startsWith('data:image')) {
|
|
src = `data:image/png;base64,${src}`;
|
|
}
|
|
img.src = src;
|
|
cache[node.id] = img;
|
|
}
|
|
});
|
|
}, [mapData]);
|
|
|
|
// --- Coordinate Transformation Helpers ---
|
|
const screenToWorld = (screenX: number, screenY: number) => {
|
|
const t = viewRef.current;
|
|
return {
|
|
x: (screenX - t.x) / t.scale,
|
|
y: (screenY - t.y) / t.scale
|
|
};
|
|
};
|
|
|
|
const worldToScreen = (worldX: number, worldY: number) => {
|
|
const t = viewRef.current;
|
|
return {
|
|
x: worldX * t.scale + t.x,
|
|
y: worldY * t.scale + t.y
|
|
};
|
|
};
|
|
|
|
// --- Physics Helper Functions ---
|
|
|
|
const rotatePoint = (px: number, py: number, cx: number, cy: number, angleDeg: number) => {
|
|
const rad = (angleDeg * Math.PI) / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
const dx = px - cx;
|
|
const dy = py - cy;
|
|
return {
|
|
x: cx + (dx * cos - dy * sin),
|
|
y: cy + (dx * sin + dy * cos),
|
|
};
|
|
};
|
|
|
|
const getDistance = (p1: { x: number, y: number }, p2: { x: number, y: number }) => {
|
|
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
|
};
|
|
|
|
// Distance from point P to line segment VW
|
|
const distanceToSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => {
|
|
const l2 = Math.pow(getDistance(v, w), 2);
|
|
if (l2 === 0) return getDistance(p, v);
|
|
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
|
|
t = Math.max(0, Math.min(1, t));
|
|
const projection = { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) };
|
|
return getDistance(p, projection);
|
|
};
|
|
|
|
// Get closest point on segment
|
|
const getClosestPointOnSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => {
|
|
const l2 = Math.pow(getDistance(v, w), 2);
|
|
if (l2 === 0) return v;
|
|
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
|
|
t = Math.max(0, Math.min(1, t));
|
|
return { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) };
|
|
};
|
|
|
|
// Quadratic Bezier Point Calculation
|
|
const getBezierPoint = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => {
|
|
const u = 1 - t;
|
|
const tt = t * t;
|
|
const uu = u * u;
|
|
|
|
return {
|
|
x: (uu * p0.x) + (2 * u * t * p1.x) + (tt * p2.x),
|
|
y: (uu * p0.y) + (2 * u * t * p1.y) + (tt * p2.y)
|
|
};
|
|
};
|
|
|
|
// Calculate derivative (tangent vector) at t for Quadratic Bezier
|
|
const getBezierDerivative = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => {
|
|
const u = 1 - t;
|
|
// B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1)
|
|
const dx = 2 * u * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
|
|
const dy = 2 * u * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);
|
|
return { x: dx, y: dy };
|
|
};
|
|
|
|
// Approximate distance to a Quadratic Bezier Curve by subdividing it
|
|
// Now uses 50 segments for higher resolution to prevent "warping"
|
|
const distanceToBezier = (p: { x: number, y: number }, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }, segments = 50) => {
|
|
let minDist = Number.MAX_VALUE;
|
|
let prevPoint = p0;
|
|
let closestProj = p0;
|
|
let bestT = 0;
|
|
|
|
// Scan segments to find approximate closest point
|
|
for (let i = 1; i <= segments; i++) {
|
|
const t = i / segments;
|
|
const currPoint = getBezierPoint(t, p0, p1, p2);
|
|
const dist = distanceToSegment(p, prevPoint, currPoint);
|
|
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
// Get linear projection on this segment to approximate t
|
|
const proj = getClosestPointOnSegment(p, prevPoint, currPoint);
|
|
closestProj = proj;
|
|
|
|
// Approximate t at projection (Lerp)
|
|
const segLen = getDistance(prevPoint, currPoint);
|
|
const distOnSeg = getDistance(prevPoint, proj);
|
|
const tStep = 1 / segments;
|
|
const tStart = (i - 1) / segments;
|
|
bestT = tStart + (distOnSeg / segLen) * tStep;
|
|
}
|
|
prevPoint = currPoint;
|
|
}
|
|
|
|
// Calculate Exact Tangent using Derivative at bestT
|
|
const deriv = getBezierDerivative(bestT, p0, p1, p2);
|
|
const tangentAngle = Math.atan2(deriv.y, deriv.x) * (180 / Math.PI);
|
|
|
|
return { dist: minDist, proj: closestProj, angle: tangentAngle };
|
|
};
|
|
|
|
const normalizeAngle = (angle: number) => {
|
|
let a = angle % 360;
|
|
if (a > 180) a -= 360;
|
|
if (a <= -180) a += 360;
|
|
return a;
|
|
};
|
|
|
|
const doSegmentsIntersect = (p1: { x: number, y: number }, p2: { x: number, y: number }, p3: { x: number, y: number }, p4: { x: number, y: number }) => {
|
|
const ccw = (a: { x: number, y: number }, b: { x: number, y: number }, c: { x: number, y: number }) => (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
|
|
return (ccw(p1, p2, p3) !== ccw(p1, p2, p4)) && (ccw(p3, p4, p1) !== ccw(p3, p4, p2));
|
|
};
|
|
|
|
// Helper to find closest magnet and its angle
|
|
const getClosestMagnetInfo = (x: number, y: number) => {
|
|
const map = mapDataRef.current;
|
|
const p = { x, y };
|
|
let closestMag = null;
|
|
let minDist = 30; // Search radius
|
|
let angle = 0;
|
|
|
|
map.magnets.forEach(mag => {
|
|
let dist = 999;
|
|
let magAngle = 0;
|
|
|
|
if (mag.type === 'STRAIGHT') {
|
|
dist = distanceToSegment(p, mag.p1, mag.p2);
|
|
magAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI);
|
|
} else if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
const res = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2);
|
|
dist = res.dist;
|
|
magAngle = res.angle;
|
|
}
|
|
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
closestMag = mag;
|
|
angle = magAngle;
|
|
}
|
|
});
|
|
return { magnet: closestMag, angle };
|
|
};
|
|
|
|
// --- Hit Test Logic ---
|
|
const getObjectsAt = (x: number, y: number, tolerance = 15): HitObject[] => {
|
|
const hits: HitObject[] = [];
|
|
const map = mapDataRef.current;
|
|
const p = { x, y };
|
|
|
|
const worldTolerance = tolerance / viewRef.current.scale;
|
|
|
|
// Type Priority Map: Smaller number = Higher Priority
|
|
const typePriority: Record<string, number> = {
|
|
'NODE': 1,
|
|
'MARK': 2,
|
|
'EDGE': 3,
|
|
'MAGNET': 4
|
|
};
|
|
|
|
// 1. Nodes
|
|
map.nodes.forEach(node => {
|
|
if (getDistance(p, node) < worldTolerance) {
|
|
hits.push({ type: 'NODE', id: node.id, name: node.name || node.id, dist: getDistance(p, node) });
|
|
}
|
|
});
|
|
|
|
// 2. Marks (Calculate as segment for better hit detection)
|
|
map.marks.forEach(mark => {
|
|
// Visual width is 50px (-25 to 25).
|
|
const rad = (mark.rotation * Math.PI) / 180;
|
|
const dx = Math.cos(rad) * 25;
|
|
const dy = Math.sin(rad) * 25;
|
|
const p1 = { x: mark.x - dx, y: mark.y - dy };
|
|
const p2 = { x: mark.x + dx, y: mark.y + dy };
|
|
|
|
const dist = distanceToSegment(p, p1, p2);
|
|
|
|
if (dist < worldTolerance) {
|
|
hits.push({ type: 'MARK', id: mark.id, name: 'Mark', dist });
|
|
}
|
|
});
|
|
|
|
// 3. Magnets
|
|
map.magnets.forEach(mag => {
|
|
let dist = 999;
|
|
if (mag.type === 'STRAIGHT') {
|
|
dist = distanceToSegment(p, mag.p1, mag.p2);
|
|
} else if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
dist = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2).dist;
|
|
}
|
|
if (dist < worldTolerance) {
|
|
hits.push({ type: 'MAGNET', id: mag.id, name: `Magnet (${mag.type})`, dist });
|
|
}
|
|
});
|
|
|
|
// 4. Edges
|
|
map.edges.forEach(edge => {
|
|
const start = map.nodes.find(n => n.id === edge.from);
|
|
const end = map.nodes.find(n => n.id === edge.to);
|
|
if (start && end) {
|
|
const dist = distanceToSegment(p, start, end);
|
|
if (dist < worldTolerance / 2) {
|
|
hits.push({ type: 'EDGE', id: edge.id, name: `Line ${edge.from}-${edge.to}`, dist });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sort by Priority first, then Distance
|
|
return hits.sort((a, b) => {
|
|
const prioDiff = typePriority[a.type] - typePriority[b.type];
|
|
if (prioDiff !== 0) return prioDiff;
|
|
return a.dist - b.dist;
|
|
});
|
|
};
|
|
|
|
const deleteObject = (item: HitObject) => {
|
|
// If the item clicked is part of selection, delete ALL selected.
|
|
// If not part of selection, delete just that item.
|
|
const idsToDelete = new Set<string>();
|
|
if (selectedItemIds.has(item.id)) {
|
|
selectedItemIds.forEach(id => idsToDelete.add(id));
|
|
} else {
|
|
idsToDelete.add(item.id);
|
|
}
|
|
|
|
setSelectedItemIds(prev => {
|
|
const next = new Set(prev);
|
|
idsToDelete.forEach(id => next.delete(id));
|
|
return next;
|
|
});
|
|
|
|
setMapData(prev => {
|
|
const next = { ...prev };
|
|
// Filter out all deleted IDs
|
|
next.nodes = prev.nodes.filter(n => !idsToDelete.has(n.id));
|
|
// Remove edges connected to deleted nodes
|
|
next.edges = prev.edges.filter(e => !idsToDelete.has(e.id) && !idsToDelete.has(e.from) && !idsToDelete.has(e.to));
|
|
next.marks = prev.marks.filter(m => !idsToDelete.has(m.id));
|
|
next.magnets = prev.magnets.filter(m => !idsToDelete.has(m.id));
|
|
return next;
|
|
});
|
|
setContextMenu(null);
|
|
};
|
|
|
|
// --- Main Simulation Loop ---
|
|
useEffect(() => {
|
|
let animationFrameId: number;
|
|
|
|
const updatePhysics = () => {
|
|
const state = { ...agvStateRef.current };
|
|
const map = mapDataRef.current;
|
|
let hasChanged = false;
|
|
logThrottleRef.current++;
|
|
|
|
// Update sensors
|
|
const frontSensor = rotatePoint(
|
|
state.x + SENSOR_OFFSET_FRONT.x,
|
|
state.y + SENSOR_OFFSET_FRONT.y,
|
|
state.x, state.y, state.rotation
|
|
);
|
|
const rearSensor = rotatePoint(
|
|
state.x + SENSOR_OFFSET_REAR.x,
|
|
state.y + SENSOR_OFFSET_REAR.y,
|
|
state.x, state.y, state.rotation
|
|
);
|
|
const markSensor = rotatePoint(
|
|
state.x + SENSOR_OFFSET_MARK.x,
|
|
state.y + SENSOR_OFFSET_MARK.y,
|
|
state.x, state.y, state.rotation
|
|
);
|
|
|
|
if (!prevMarkSensorPosRef.current) prevMarkSensorPosRef.current = markSensor;
|
|
|
|
let lineFrontDetected = false;
|
|
let lineRearDetected = false;
|
|
|
|
// FIXED: Very tight threshold to ensure the sensor must physically be ON the line
|
|
// Reduced from 12 to 8 to prevent "grazing" false positives during tight turns
|
|
const DETECTION_THRESHOLD = 8;
|
|
const SEARCH_RADIUS = 30;
|
|
|
|
// 1. Check Strict Sensor Status (for UI/State)
|
|
map.magnets.forEach(mag => {
|
|
let distFront = 999;
|
|
let distRear = 999;
|
|
|
|
if (mag.type === 'STRAIGHT') {
|
|
distFront = distanceToSegment(frontSensor, mag.p1, mag.p2);
|
|
distRear = distanceToSegment(rearSensor, mag.p1, mag.p2);
|
|
} else if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
distFront = distanceToBezier(frontSensor, mag.p1, mag.controlPoint, mag.p2).dist;
|
|
distRear = distanceToBezier(rearSensor, mag.p1, mag.controlPoint, mag.p2).dist;
|
|
}
|
|
|
|
if (distFront < DETECTION_THRESHOLD) lineFrontDetected = true;
|
|
if (distRear < DETECTION_THRESHOLD) lineRearDetected = true;
|
|
});
|
|
|
|
// Handle Line Out Logic
|
|
const isAutoMove = state.motionState === AgvMotionState.RUNNING || state.motionState === AgvMotionState.MARK_STOPPING;
|
|
|
|
if (state.motionState !== AgvMotionState.IDLE && !state.error) {
|
|
hasChanged = true;
|
|
let moveSpeed = state.speed;
|
|
|
|
if (state.motionState === AgvMotionState.RUNNING) {
|
|
moveSpeed = state.runConfig.speedLevel === 'L' ? SPEED_L : state.runConfig.speedLevel === 'M' ? SPEED_M : SPEED_H;
|
|
} else if (state.motionState === AgvMotionState.MARK_STOPPING) {
|
|
moveSpeed = Math.min(state.speed, MARK_SEARCH_SPEED);
|
|
}
|
|
|
|
// --- LINE TRACING ---
|
|
if (isAutoMove) {
|
|
const isFwd = state.runConfig.direction === 'FWD';
|
|
const sensorPos = isFwd ? frontSensor : rearSensor;
|
|
|
|
let bestMagnet: { mag: MagnetLine, dist: number, targetAngle: number, proj: { x: number, y: number } } | null = null;
|
|
|
|
// Collect all candidate lines
|
|
const potentialLines: { mag: MagnetLine, dist: number, proj: { x: number, y: number }, targetAngle: number, angleDiff: number, category: string, isSticky: boolean, isBehind: boolean, isConnected: boolean, isSharpTurn: boolean }[] = [];
|
|
|
|
map.magnets.forEach(mag => {
|
|
let dist = 999;
|
|
let proj = { x: 0, y: 0 };
|
|
let tangentAngle = 0;
|
|
|
|
if (mag.type === 'STRAIGHT') {
|
|
dist = distanceToSegment(sensorPos, mag.p1, mag.p2);
|
|
proj = getClosestPointOnSegment(sensorPos, mag.p1, mag.p2);
|
|
tangentAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI);
|
|
} else if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
const res = distanceToBezier(sensorPos, mag.p1, mag.controlPoint, mag.p2);
|
|
dist = res.dist;
|
|
proj = res.proj;
|
|
tangentAngle = res.angle;
|
|
}
|
|
|
|
if (dist < SEARCH_RADIUS) {
|
|
let heading = state.rotation;
|
|
if (!isFwd) heading += 180;
|
|
heading = normalizeAngle(heading);
|
|
|
|
// Align tangent to heading for basic diff calc
|
|
let alignedTangent = tangentAngle;
|
|
if (mag.type === 'STRAIGHT') {
|
|
let diff = normalizeAngle(tangentAngle - heading);
|
|
if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180);
|
|
} else {
|
|
let diff = normalizeAngle(tangentAngle - heading);
|
|
if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180);
|
|
}
|
|
|
|
let diff = normalizeAngle(alignedTangent - heading);
|
|
|
|
// --- Node-based Lookahead / Category ---
|
|
let comparisonAngle = alignedTangent;
|
|
const d1 = getDistance(sensorPos, mag.p1);
|
|
const d2 = getDistance(sensorPos, mag.p2);
|
|
const farEnd = d1 > d2 ? mag.p1 : mag.p2; // Heuristic for segment end
|
|
const targetNode = map.nodes.find(n => getDistance(n, farEnd) < 40);
|
|
|
|
if (targetNode) {
|
|
const nodeAngle = Math.atan2(targetNode.y - sensorPos.y, targetNode.x - sensorPos.x) * (180 / Math.PI);
|
|
comparisonAngle = normalizeAngle(nodeAngle);
|
|
}
|
|
|
|
let finalDiff = normalizeAngle(comparisonAngle - heading);
|
|
let category = 'STRAIGHT';
|
|
if (finalDiff < -45) category = 'LEFT';
|
|
else if (finalDiff > 45) category = 'RIGHT';
|
|
|
|
// --- Behind Check ---
|
|
const dx = proj.x - sensorPos.x;
|
|
const dy = proj.y - sensorPos.y;
|
|
const hRad = (heading * Math.PI) / 180;
|
|
const hx = Math.cos(hRad);
|
|
const hy = Math.sin(hRad);
|
|
|
|
let isSticky = lastMagnetIdRef.current === mag.id;
|
|
|
|
// Release Sticky if End of Line Reached
|
|
if (isSticky) {
|
|
const distToP1 = getDistance(sensorPos, mag.p1);
|
|
const distToP2 = getDistance(sensorPos, mag.p2);
|
|
if (distToP1 < 25 || distToP2 < 25) {
|
|
isSticky = false;
|
|
}
|
|
}
|
|
|
|
// Dot product check
|
|
const behindThreshold = isSticky ? -25 : -5;
|
|
const isBehind = (dx * hx + dy * hy) < behindThreshold;
|
|
|
|
// --- STRICT CONNECTIVITY CHECK ---
|
|
const distToEndpoint = Math.min(getDistance(sensorPos, mag.p1), getDistance(sensorPos, mag.p2));
|
|
const isConnected = distToEndpoint < 30;
|
|
|
|
// --- PHYSICAL CONSTRAINT (SHARP TURN) ---
|
|
// If deflection is > 35 degrees (Internal angle < 145), it is too sharp for normal line tracking
|
|
// This forces the AGV to ignore 90-degree junctions unless there is a curve magnet
|
|
const isSharpTurn = Math.abs(diff) > 35;
|
|
|
|
potentialLines.push({ mag, dist, proj, targetAngle: alignedTangent, angleDiff: diff, category, isSticky, isBehind, isConnected, isSharpTurn });
|
|
}
|
|
});
|
|
|
|
// Priority Sort
|
|
const branchMode = state.runConfig.branch;
|
|
|
|
potentialLines.sort((a, b) => {
|
|
// 1. Behind Check (Absolute disqualifier usually)
|
|
if (a.isBehind !== b.isBehind) return a.isBehind ? 1 : -1;
|
|
|
|
// 2. PHYSICAL CONSTRAINT: Disqualify Sharp Turns
|
|
// A sharp turn is physically impossible in motion, so we deprioritize it heavily.
|
|
if (a.isSharpTurn !== b.isSharpTurn) return a.isSharpTurn ? 1 : -1;
|
|
|
|
const aValid = a.isSticky || a.isConnected;
|
|
const bValid = b.isSticky || b.isConnected;
|
|
|
|
// 3. Validity Check: We prefer lines that are physically anchored here
|
|
if (aValid !== bValid) return aValid ? -1 : 1;
|
|
|
|
// 4. Branch Matching (Only among valid lines)
|
|
if (aValid && bValid) {
|
|
const aMatch = a.category === branchMode;
|
|
const bMatch = b.category === branchMode;
|
|
if (aMatch !== bMatch) return aMatch ? -1 : 1;
|
|
}
|
|
|
|
// 5. Stickiness (Stability bias)
|
|
if (a.isSticky !== b.isSticky) return a.isSticky ? -1 : 1;
|
|
|
|
// 6. Connectivity (Prefer line starts over mid-lines)
|
|
if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1;
|
|
|
|
// 7. Distance
|
|
return a.dist - b.dist;
|
|
});
|
|
|
|
// Debug Logging (Throttled ~ 30 frames)
|
|
if (logThrottleRef.current % 30 === 0 && isAutoMove) {
|
|
const logMsg = `Trk: found=${potentialLines.length} | ` +
|
|
potentialLines.map(p => `[${p.mag.id.substring(0, 4)} D:${Math.round(p.dist)} S:${p.isSticky ? 'Y' : 'N'} M:${p.category === branchMode ? 'Y' : 'N'} !${p.isSharpTurn ? 'SHARP' : 'OK'}]`).join(' ');
|
|
onLog(logMsg);
|
|
}
|
|
|
|
if (potentialLines.length > 0) {
|
|
bestMagnet = potentialLines[0];
|
|
lastMagnetIdRef.current = bestMagnet.mag.id;
|
|
}
|
|
|
|
// *** LINE OUT CHECK INTEGRATED HERE ***
|
|
// Only error out if NO valid lines are found in search radius
|
|
if (!bestMagnet && isAutoMove) {
|
|
state.motionState = AgvMotionState.IDLE;
|
|
state.error = 'LINE_OUT';
|
|
hasChanged = true;
|
|
onLog(`STOPPED: LINE_OUT. Sensors: F=${lineFrontDetected}, R=${lineRearDetected}`);
|
|
} else if (bestMagnet) {
|
|
// Adaptive Steering
|
|
const angleError = normalizeAngle(bestMagnet.targetAngle - (isFwd ? state.rotation : state.rotation + 180));
|
|
|
|
// Sharp turn handling - Reduced aggression to prevent jitter
|
|
const steerFactor = Math.abs(angleError) > 20 ? 0.1 : 0.05;
|
|
state.rotation += angleError * steerFactor;
|
|
|
|
const driftX = bestMagnet.proj.x - sensorPos.x;
|
|
const driftY = bestMagnet.proj.y - sensorPos.y;
|
|
|
|
// Position correction (Damping + Clamping)
|
|
// This prevents "Warping" when switching to lines that are found far away (e.g. 60px)
|
|
const driftFactor = 0.1; // Reduced from 0.3 for smoother path following
|
|
let moveX = driftX * driftFactor;
|
|
let moveY = driftY * driftFactor;
|
|
|
|
// CLAMP: Max lateral snap per frame to prevent teleportation
|
|
const MAX_SNAP = 2.0;
|
|
const moveDist = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
if (moveDist > MAX_SNAP) {
|
|
const ratio = MAX_SNAP / moveDist;
|
|
moveX *= ratio;
|
|
moveY *= ratio;
|
|
}
|
|
|
|
state.x += moveX;
|
|
state.y += moveY;
|
|
|
|
if (bestMagnet.mag.type === 'CURVE') {
|
|
// Extra rotation correction for curves is now handled better by Derivative angle
|
|
// But we keep a small correctional factor for drift
|
|
const newSensorX = sensorPos.x + driftX;
|
|
const newSensorY = sensorPos.y + driftY;
|
|
const otherSensor = isFwd ? rearSensor : frontSensor;
|
|
const dx = newSensorX - otherSensor.x;
|
|
const dy = newSensorY - otherSensor.y;
|
|
let newAngle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
if (!isFwd) newAngle += 180;
|
|
let diff = normalizeAngle(newAngle - state.rotation);
|
|
state.rotation += diff * 0.1; // Reduced significantly as derivative is more accurate
|
|
}
|
|
|
|
// Apply Movement
|
|
const rad = (state.rotation * Math.PI) / 180;
|
|
const dirMult = isFwd ? 1 : -1;
|
|
state.x += Math.cos(rad) * moveSpeed * dirMult;
|
|
state.y += Math.sin(rad) * moveSpeed * dirMult;
|
|
}
|
|
|
|
} else {
|
|
// Manual Movements (unchanged)
|
|
const rad = (state.rotation * Math.PI) / 180;
|
|
if (state.motionState === AgvMotionState.FORWARD) {
|
|
state.x += Math.cos(rad) * moveSpeed;
|
|
state.y += Math.sin(rad) * moveSpeed;
|
|
} else if (state.motionState === AgvMotionState.BACKWARD) {
|
|
state.x -= Math.cos(rad) * moveSpeed;
|
|
state.y -= Math.sin(rad) * moveSpeed;
|
|
} else if (state.motionState === AgvMotionState.TURN_LEFT) state.rotation -= TURN_SPEED;
|
|
else if (state.motionState === AgvMotionState.TURN_RIGHT) state.rotation += TURN_SPEED;
|
|
else if (state.motionState === AgvMotionState.TURN_LEFT_180) {
|
|
state.rotation -= TURN_SPEED;
|
|
// 180 Turn Logic (L-Turn = CCW):
|
|
// Stop when REAR sensor detects a new line (e.g. 90 degree cross), but ignore first ~60 degrees to clear original line.
|
|
|
|
const startRot = state.targetRotation !== null ? state.targetRotation + 180 : state.rotation;
|
|
const degreesTurned = startRot - state.rotation; // Positive increasing
|
|
|
|
const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 to prevent early false detection
|
|
|
|
// L-Turn: Check REAR sensor for stop condition
|
|
const stopCondition = !ignoreSensors && lineRearDetected;
|
|
const reachedLimit = (state.targetRotation !== null && state.rotation <= state.targetRotation);
|
|
|
|
// Debug Log
|
|
if (logThrottleRef.current % 10 === 0) {
|
|
onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} RearLine=${lineRearDetected ? 'YES' : 'NO'}`);
|
|
}
|
|
|
|
if (stopCondition || reachedLimit) {
|
|
if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation;
|
|
state.motionState = AgvMotionState.IDLE; state.targetRotation = null;
|
|
onLog(`Turn180 [L] Complete. Reason: ${stopCondition ? 'Rear Sensor Hit' : 'Angle Reached'}`);
|
|
}
|
|
} else if (state.motionState === AgvMotionState.TURN_RIGHT_180) {
|
|
state.rotation += TURN_SPEED;
|
|
// 180 Turn Logic (R-Turn = CW):
|
|
// Stop when FRONT sensor detects a new line, but ignore first ~60 degrees.
|
|
|
|
const startRot = state.targetRotation !== null ? state.targetRotation - 180 : state.rotation;
|
|
const degreesTurned = state.rotation - startRot; // Positive increasing
|
|
|
|
const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60
|
|
|
|
// R-Turn: Check FRONT sensor for stop condition
|
|
const stopCondition = !ignoreSensors && lineFrontDetected;
|
|
const reachedLimit = (state.targetRotation !== null && state.rotation >= state.targetRotation);
|
|
|
|
// Debug Log
|
|
if (logThrottleRef.current % 10 === 0) {
|
|
onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} FrontLine=${lineFrontDetected ? 'YES' : 'NO'}`);
|
|
}
|
|
|
|
if (stopCondition || reachedLimit) {
|
|
if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation;
|
|
state.motionState = AgvMotionState.IDLE; state.targetRotation = null;
|
|
onLog(`Turn180 [R] Complete. Reason: ${stopCondition ? 'Front Sensor Hit' : 'Angle Reached'}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
state.sensorLineFront = lineFrontDetected;
|
|
state.sensorLineRear = lineRearDetected;
|
|
|
|
let rfidFound: string | null = null;
|
|
map.nodes.forEach(node => { if (node.rfidId && getDistance({ x: state.x, y: state.y }, node) < 15) rfidFound = node.rfidId; });
|
|
state.detectedRfid = rfidFound;
|
|
|
|
let markFound = false;
|
|
const prevSensorPos = prevMarkSensorPosRef.current;
|
|
if (prevSensorPos) {
|
|
map.marks.forEach(mark => {
|
|
const rad = (mark.rotation * Math.PI) / 180;
|
|
const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25;
|
|
const ms = { x: mark.x - dx, y: mark.y - dy }; const me = { x: mark.x + dx, y: mark.y + dy };
|
|
|
|
// Check 1: Dynamic Crossing (Intersection)
|
|
if (doSegmentsIntersect(prevSensorPos, markSensor, ms, me)) {
|
|
markFound = true;
|
|
}
|
|
|
|
// Check 2: Static Proximity (Distance to line segment)
|
|
// This ensures sensor stays ON when sitting on the mark
|
|
if (!markFound) {
|
|
const distToMarkLine = distanceToSegment(markSensor, ms, me);
|
|
// If within 10px of the mark tape (tape width is technically line width but visually 3px, sensor range is wider)
|
|
if (distToMarkLine < 10) {
|
|
markFound = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
prevMarkSensorPosRef.current = { x: markSensor.x, y: markSensor.y };
|
|
state.sensorMark = markFound;
|
|
if (markFound && state.motionState === AgvMotionState.MARK_STOPPING) { state.motionState = AgvMotionState.IDLE; hasChanged = true; }
|
|
|
|
if (hasChanged || state.sensorLineFront !== agvStateRef.current.sensorLineFront || state.sensorMark !== agvStateRef.current.sensorMark || state.error !== agvStateRef.current.error || state.rotation !== agvStateRef.current.rotation) {
|
|
setAgvState(prev => {
|
|
// Conflict Resolution:
|
|
// If the motionState has changed externally (e.g. STOP command from App.tsx), we must yield to it.
|
|
// We compare the 'prev' (latest React state) with the 'agvStateRef.current' (state at start of frame).
|
|
if (prev.motionState !== agvStateRef.current.motionState) {
|
|
return prev;
|
|
}
|
|
return state;
|
|
});
|
|
}
|
|
};
|
|
|
|
const draw = () => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Clear Screen
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// --- Apply Zoom & Pan ---
|
|
const t = viewRef.current;
|
|
ctx.save();
|
|
ctx.translate(t.x, t.y);
|
|
ctx.scale(t.scale, t.scale);
|
|
|
|
// --- Draw Infinite Grid ---
|
|
const visibleLeft = -t.x / t.scale;
|
|
const visibleTop = -t.y / t.scale;
|
|
const visibleRight = (canvas.width - t.x) / t.scale;
|
|
const visibleBottom = (canvas.height - t.y) / t.scale;
|
|
|
|
const startX = Math.floor(visibleLeft / GRID_SIZE) * GRID_SIZE;
|
|
const endX = Math.floor(visibleRight / GRID_SIZE) * GRID_SIZE + GRID_SIZE;
|
|
const startY = Math.floor(visibleTop / GRID_SIZE) * GRID_SIZE;
|
|
const endY = Math.floor(visibleBottom / GRID_SIZE) * GRID_SIZE + GRID_SIZE;
|
|
|
|
ctx.strokeStyle = '#374151';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
// Verticals
|
|
for (let x = startX; x <= endX; x += GRID_SIZE) {
|
|
ctx.moveTo(x, startY); ctx.lineTo(x, endY);
|
|
}
|
|
// Horizontals
|
|
for (let y = startY; y <= endY; y += GRID_SIZE) {
|
|
ctx.moveTo(startX, y); ctx.lineTo(endX, y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
const map = mapDataRef.current;
|
|
|
|
// 1. Logical Edges (Thin Gray)
|
|
ctx.strokeStyle = '#4B5563';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 2]);
|
|
map.edges.forEach(edge => {
|
|
const start = map.nodes.find(n => n.id === edge.from);
|
|
const end = map.nodes.find(n => n.id === edge.to);
|
|
if (start && end) {
|
|
ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke();
|
|
}
|
|
});
|
|
ctx.setLineDash([]);
|
|
|
|
// 2. Magnet Lines (Wide, Transparent Blue)
|
|
ctx.lineWidth = MAGNET_WIDTH;
|
|
ctx.lineCap = 'round';
|
|
map.magnets.forEach(mag => {
|
|
const isSelected = selectedItemIds.has(mag.id);
|
|
ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(mag.p1.x, mag.p1.y);
|
|
if (mag.type === 'STRAIGHT') {
|
|
ctx.lineTo(mag.p2.x, mag.p2.y);
|
|
} else if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
ctx.quadraticCurveTo(mag.controlPoint.x, mag.controlPoint.y, mag.p2.x, mag.p2.y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw Controls for Selected Curve (Only if Single Selection for Simplicity handles)
|
|
if (isSelected && selectedItemIds.size === 1) {
|
|
if (mag.type === 'CURVE' && mag.controlPoint) {
|
|
// Helper Lines
|
|
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(mag.p1.x, mag.p1.y);
|
|
ctx.lineTo(mag.controlPoint.x, mag.controlPoint.y);
|
|
ctx.lineTo(mag.p2.x, mag.p2.y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Control Points
|
|
const drawHandle = (p: { x: number, y: number }, color: string) => {
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill(); // Scale handle size
|
|
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
|
|
};
|
|
drawHandle(mag.p1, '#3B82F6'); // Start (Blue)
|
|
drawHandle(mag.p2, '#3B82F6'); // End (Blue)
|
|
drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow)
|
|
} else if (mag.type === 'STRAIGHT') {
|
|
// Simple handles for straight lines
|
|
const drawHandle = (p: { x: number, y: number }, color: string) => {
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
|
|
};
|
|
drawHandle(mag.p1, '#3B82F6');
|
|
drawHandle(mag.p2, '#3B82F6');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Nodes (Unchanged)
|
|
const images = imageCacheRef.current;
|
|
map.nodes.forEach(node => {
|
|
if (node.type === NodeType.Image && images[node.id]) {
|
|
const img = images[node.id];
|
|
if (img.complete) {
|
|
const w = 100; const h = (img.height / img.width) * w;
|
|
ctx.drawImage(img, node.x - w / 2, node.y - h / 2, w, h);
|
|
}
|
|
return;
|
|
}
|
|
if (node.type === NodeType.Label && node.labelText) {
|
|
ctx.font = 'bold 20px Arial';
|
|
const tm = ctx.measureText(node.labelText);
|
|
const w = tm.width + 20; const h = 30;
|
|
if (node.backColor !== 'Transparent') { ctx.fillStyle = node.backColor!; ctx.fillRect(node.x - w / 2, node.y - h / 2, w, h); }
|
|
ctx.fillStyle = node.foreColor || 'White'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.labelText, node.x, node.y);
|
|
return;
|
|
}
|
|
let color = '#4B5563';
|
|
if (node.type === NodeType.Loader || node.type === NodeType.UnLoader) color = 'green';
|
|
else if (node.type === NodeType.Charging || node.type === NodeType.ChargerStation) color = 'magenta';
|
|
else if (node.type === NodeType.Buffer) color = 'green';
|
|
else if (node.displayColor) color = node.displayColor;
|
|
|
|
// Highlight Selected Node
|
|
if (selectedItemIds.has(node.id)) {
|
|
ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2;
|
|
ctx.strokeRect(node.x - 10, node.y - 10, 20, 20);
|
|
}
|
|
|
|
ctx.fillStyle = color;
|
|
if (node.type !== NodeType.Normal) ctx.fillRect(node.x - 6, node.y - 6, 12, 12);
|
|
else { ctx.beginPath(); ctx.arc(node.x, node.y, 4, 0, Math.PI * 2); ctx.fill(); }
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.font = '10px monospace'; ctx.fillStyle = '#9CA3AF';
|
|
const topText = node.rfidId ? `[${node.rfidId}]` : node.id; ctx.fillText(topText, node.x, node.y - 10);
|
|
if (node.name) { ctx.font = 'bold 10px Arial'; ctx.fillStyle = '#FFFFFF'; ctx.fillText(node.name, node.x, node.y + 18); }
|
|
});
|
|
|
|
// Marks
|
|
ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3;
|
|
map.marks.forEach(mark => {
|
|
const isSelected = selectedItemIds.has(mark.id);
|
|
if (isSelected) {
|
|
ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10;
|
|
} else {
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180);
|
|
ctx.beginPath(); ctx.moveTo(-25, 0); ctx.lineTo(25, 0); ctx.stroke(); ctx.restore();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw handles if selected
|
|
if (isSelected && selectedItemIds.size === 1) {
|
|
|
|
// Re-apply transform for simple drawing in local space
|
|
ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180);
|
|
|
|
// Handles
|
|
const drawHandle = (x: number, y: number) => {
|
|
ctx.fillStyle = '#EAB308';
|
|
ctx.beginPath(); ctx.arc(x, y, 4 / t.scale, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
|
|
};
|
|
drawHandle(-25, 0); // Start
|
|
drawHandle(25, 0); // End
|
|
|
|
ctx.restore();
|
|
}
|
|
});
|
|
|
|
// Selection Box
|
|
if (selectionBox) {
|
|
const minX = Math.min(selectionBox.start.x, selectionBox.current.x);
|
|
const minY = Math.min(selectionBox.start.y, selectionBox.current.y);
|
|
const w = Math.abs(selectionBox.current.x - selectionBox.start.x);
|
|
const h = Math.abs(selectionBox.current.y - selectionBox.start.y);
|
|
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
|
ctx.strokeStyle = '#3B82F6';
|
|
ctx.lineWidth = 1;
|
|
ctx.fillRect(minX, minY, w, h);
|
|
ctx.strokeRect(minX, minY, w, h);
|
|
}
|
|
|
|
// Drawing Helpers
|
|
// -- Logical Edge (Connection) --
|
|
if (activeTool === ToolType.DRAW_LINE && dragStartPos) {
|
|
ctx.strokeStyle = '#9CA3AF'; // Gray
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]); // Dashed
|
|
ctx.beginPath();
|
|
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
|
const curr = mouseWorldPosRef.current;
|
|
ctx.lineTo(curr.x, curr.y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// -- Straight Magnet --
|
|
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) {
|
|
// ... (Keep existing drawing logic)
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
|
ctx.lineWidth = MAGNET_WIDTH;
|
|
ctx.beginPath();
|
|
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
|
|
|
const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
|
ctx.lineTo(curr.x, curr.y);
|
|
ctx.stroke();
|
|
}
|
|
if (activeTool === ToolType.DRAW_MAGNET_CURVE && tempMagnet) {
|
|
// ... (Keep existing drawing logic - ensuring it's not lost)
|
|
const p1 = tempMagnet.p1!;
|
|
const p2 = tempMagnet.p2 || getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
|
|
|
// Visualize Construction
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([2, 2]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
|
|
let control = tempMagnet.controlPoint;
|
|
if (curvePhase === 1) {
|
|
control = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
ctx.lineTo(p2.x, p2.y);
|
|
} else if (curvePhase === 2) {
|
|
control = mouseWorldPosRef.current;
|
|
ctx.lineTo(control.x, control.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
if (control) {
|
|
ctx.fillStyle = '#60A5FA';
|
|
ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
|
ctx.lineWidth = MAGNET_WIDTH;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
if (control) {
|
|
ctx.quadraticCurveTo(control.x, control.y, p2.x, p2.y);
|
|
} else {
|
|
ctx.lineTo(p2.x, p2.y);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
// AGV Drawing (Same as before)
|
|
const state = agvStateRef.current;
|
|
ctx.save(); ctx.translate(state.x, state.y); ctx.rotate((state.rotation * Math.PI) / 180);
|
|
|
|
// Draw LiDAR Scanner if active (Directional: Front or Rear based on Motor Direction)
|
|
if (state.lidarEnabled) {
|
|
const isFwd = state.runConfig.direction === 'FWD';
|
|
const sensorPos = isFwd ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR;
|
|
|
|
ctx.save();
|
|
// Arc Scanner
|
|
ctx.fillStyle = 'rgba(6, 182, 212, 0.2)'; // Cyan transparent
|
|
ctx.strokeStyle = 'rgba(6, 182, 212, 0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
|
|
const baseAngle = isFwd ? 0 : Math.PI; // 0 for Front, 180 for Rear
|
|
|
|
ctx.moveTo(sensorPos.x, sensorPos.y);
|
|
ctx.arc(sensorPos.x, sensorPos.y, 60, baseAngle - Math.PI / 6, baseAngle + Math.PI / 6); // +/- 30 degrees
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Scanning line animation
|
|
const now = Date.now();
|
|
const scanOffset = Math.sin(now / 200) * (Math.PI / 6);
|
|
const currentAngle = baseAngle + scanOffset;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(sensorPos.x, sensorPos.y);
|
|
ctx.lineTo(sensorPos.x + Math.cos(currentAngle) * 60, sensorPos.y + Math.sin(currentAngle) * 60);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.fillStyle = '#1F2937'; ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 2;
|
|
ctx.fillRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); ctx.strokeRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT);
|
|
if (state.error) { ctx.fillStyle = '#EF4444'; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#FFF'; ctx.font = 'bold 8px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('ERR', 0, 0); }
|
|
ctx.fillStyle = '#000'; ctx.fillRect(-AGV_WIDTH / 2 - 4, -10, 4, 20); ctx.fillRect(AGV_WIDTH / 2, -10, 4, 20);
|
|
ctx.fillStyle = '#10B981'; ctx.fillRect(AGV_HEIGHT / 2 - 10, -AGV_WIDTH / 2 + 5, 10, AGV_WIDTH - 10);
|
|
ctx.fillStyle = '#EF4444'; ctx.fillRect(-AGV_HEIGHT / 2 + 5, -AGV_WIDTH / 2 + 10, 15, 20);
|
|
ctx.fillStyle = '#FFF'; ctx.font = '10px Arial'; ctx.textBaseline = 'alphabetic'; ctx.fillText(`Z:${state.liftHeight}`, -AGV_HEIGHT / 2 + 12, -AGV_WIDTH / 2 + 25);
|
|
|
|
// Draw Status Info Panel (Rear)
|
|
ctx.save();
|
|
ctx.translate(-12, 0); // Move towards rear center
|
|
|
|
// Panel Background
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
ctx.strokeStyle = '#374151';
|
|
ctx.lineWidth = 1;
|
|
ctx.fillRect(-10, -18, 20, 36);
|
|
ctx.strokeRect(-10, -18, 20, 36);
|
|
|
|
// Speed (Top)
|
|
const spd = state.runConfig.speedLevel;
|
|
ctx.fillStyle = spd === 'H' ? '#F87171' : spd === 'M' ? '#FACC15' : '#4ADE80'; // Red/Yellow/Green
|
|
ctx.font = 'bold 12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(spd, 0, -8);
|
|
|
|
// Divider
|
|
ctx.strokeStyle = '#4B5563';
|
|
ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(8, 0); ctx.stroke();
|
|
|
|
// Branch (Bottom)
|
|
ctx.fillStyle = '#FFF';
|
|
const br = state.runConfig.branch;
|
|
if (br === 'LEFT') {
|
|
ctx.save(); ctx.translate(0, 10); ctx.rotate(-Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore();
|
|
} else if (br === 'RIGHT') {
|
|
ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore();
|
|
} else {
|
|
ctx.fillText('➜', 0, 10);
|
|
}
|
|
ctx.restore();
|
|
|
|
// Draw Magnet Key Icon if ON (Shifted forward to avoid status panel)
|
|
if (state.magnetOn) {
|
|
ctx.save();
|
|
ctx.translate(15, -6); // Position shifted forward
|
|
ctx.fillStyle = '#FACC15'; // Yellow/Gold
|
|
ctx.strokeStyle = '#FACC15';
|
|
ctx.lineWidth = 1.5;
|
|
|
|
// Key Head (Circle)
|
|
ctx.beginPath();
|
|
ctx.arc(0, -3, 3.5, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
// Key Hole
|
|
ctx.beginPath(); ctx.arc(0, -3, 1, 0, Math.PI * 2); ctx.fill();
|
|
|
|
// Key Shaft
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0.5);
|
|
ctx.lineTo(0, 8);
|
|
|
|
// Key Teeth
|
|
ctx.moveTo(0, 4); ctx.lineTo(3, 4);
|
|
ctx.moveTo(0, 6.5); ctx.lineTo(3, 6.5);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// Sensors
|
|
ctx.fillStyle = state.sensorLineFront ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_FRONT.x, SENSOR_OFFSET_FRONT.y, 3, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = state.sensorLineRear ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_REAR.x, SENSOR_OFFSET_REAR.y, 3, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = state.sensorMark ? '#FFFF00' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_MARK.x, SENSOR_OFFSET_MARK.y, state.sensorMark ? 6 : 4, 0, Math.PI * 2); ctx.fill(); if (state.sensorMark) { ctx.strokeStyle = '#FFF'; ctx.stroke(); }
|
|
|
|
if (state.motionState === AgvMotionState.RUNNING) {
|
|
const activeOffset = state.runConfig.direction === 'FWD' ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR;
|
|
ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(activeOffset.x, activeOffset.y, 6, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
ctx.strokeStyle = '#FFF'; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.lineTo(5, -5); ctx.moveTo(10, 0); ctx.lineTo(5, 5); ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// Restore Transform
|
|
ctx.restore();
|
|
|
|
animationFrameId = requestAnimationFrame(draw);
|
|
};
|
|
|
|
const interval = setInterval(updatePhysics, 16);
|
|
animationFrameId = requestAnimationFrame(draw);
|
|
return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); };
|
|
}, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemIds, dimensions]); // Re-draw when dimensions change
|
|
|
|
// --- Interaction Handlers ---
|
|
|
|
const snapToGrid = (val: number) => Math.round(val / (GRID_SIZE / 2)) * (GRID_SIZE / 2); // Snap to half-grid
|
|
|
|
// Intelligent Snapping: Prefer Nodes, else Raw Position
|
|
const getSnappedPos = (rawX: number, rawY: number) => {
|
|
const map = mapDataRef.current;
|
|
const SNAP_DIST = 20;
|
|
|
|
// 1. Try snapping to Nodes
|
|
let closestNode: MapNode | null = null;
|
|
let minDist = SNAP_DIST;
|
|
|
|
map.nodes.forEach(node => {
|
|
const d = Math.sqrt(Math.pow(node.x - rawX, 2) + Math.pow(node.y - rawY, 2));
|
|
if (d < minDist) {
|
|
minDist = d;
|
|
closestNode = node;
|
|
}
|
|
});
|
|
|
|
if (closestNode) {
|
|
return { x: closestNode.x, y: closestNode.y };
|
|
}
|
|
|
|
// 2. Fallback to Raw (No Grid Snap as per user request)
|
|
return { x: rawX, y: rawY };
|
|
};
|
|
|
|
const handleWheel = (e: React.WheelEvent) => {
|
|
e.preventDefault();
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
|
|
const screenX = e.clientX - rect.left;
|
|
const screenY = e.clientY - rect.top;
|
|
|
|
// Zoom Logic
|
|
const zoomIntensity = 0.1;
|
|
const delta = e.deltaY < 0 ? 1 : -1;
|
|
let newScale = viewRef.current.scale + delta * zoomIntensity;
|
|
newScale = Math.min(Math.max(0.2, newScale), 5); // Limit zoom
|
|
|
|
// World pos before zoom
|
|
const worldX = (screenX - viewRef.current.x) / viewRef.current.scale;
|
|
const worldY = (screenY - viewRef.current.y) / viewRef.current.scale;
|
|
|
|
// Adjust offset so world pos remains at screen pos
|
|
const newX = screenX - worldX * newScale;
|
|
const newY = screenY - worldY * newScale;
|
|
|
|
viewRef.current = { x: newX, y: newY, scale: newScale };
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const screenX = e.clientX - rect.left;
|
|
const screenY = e.clientY - rect.top;
|
|
|
|
mouseScreenPosRef.current = { x: screenX, y: screenY };
|
|
const worldPos = screenToWorld(screenX, screenY);
|
|
mouseWorldPosRef.current = worldPos;
|
|
|
|
// Pan
|
|
if (isPanning) {
|
|
viewRef.current = {
|
|
...viewRef.current,
|
|
x: viewRef.current.x + e.movementX,
|
|
y: viewRef.current.y + e.movementY
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Box Selection Update
|
|
if (selectionBox) {
|
|
setSelectionBox(prev => prev ? { ...prev, current: worldPos } : null);
|
|
return;
|
|
}
|
|
|
|
// Drag Handle (Editing - Only if single selection)
|
|
if (dragHandle && selectedItemIds.size === 1) {
|
|
const singleId = Array.from(selectedItemIds)[0];
|
|
const snapped = getSnappedPos(worldPos.x, worldPos.y);
|
|
setMapData(prev => {
|
|
const next = { ...prev };
|
|
if (dragHandle === 'p1' || dragHandle === 'p2' || dragHandle === 'control') {
|
|
const magIndex = next.magnets.findIndex(m => m.id === singleId);
|
|
if (magIndex !== -1) {
|
|
const newMags = [...next.magnets];
|
|
const mag = { ...newMags[magIndex] };
|
|
if (dragHandle === 'p1') mag.p1 = snapped;
|
|
else if (dragHandle === 'p2') mag.p2 = snapped;
|
|
else if (dragHandle === 'control') mag.controlPoint = worldPos; // No snap for control
|
|
newMags[magIndex] = mag;
|
|
next.magnets = newMags;
|
|
}
|
|
} else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') {
|
|
// Rotate Mark
|
|
const markIndex = next.marks.findIndex(m => m.id === singleId);
|
|
if (markIndex !== -1) {
|
|
const newMarks = [...next.marks];
|
|
const mark = { ...newMarks[markIndex] };
|
|
const dx = worldPos.x - mark.x;
|
|
const dy = worldPos.y - mark.y;
|
|
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
if (dragHandle === 'mark_p1') angle += 180;
|
|
mark.rotation = normalizeAngle(angle);
|
|
newMarks[markIndex] = mark;
|
|
next.marks = newMarks;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Drag Object (Moving)
|
|
if (dragTarget) {
|
|
const dx = worldPos.x - dragTarget.startMouse.x;
|
|
const dy = worldPos.y - dragTarget.startMouse.y;
|
|
|
|
if (dragTarget.type === 'AGV' && dragTarget.initialAgvState) {
|
|
setAgvState(prev => ({
|
|
...prev,
|
|
x: dragTarget.initialAgvState!.x + dx,
|
|
y: dragTarget.initialAgvState!.y + dy
|
|
}));
|
|
} else if (dragTarget.type === 'MULTI' && dragTarget.initialStates) {
|
|
const initialStates = dragTarget.initialStates;
|
|
setMapData(prev => {
|
|
const next = { ...prev };
|
|
|
|
// Update Nodes
|
|
next.nodes = prev.nodes.map(n => {
|
|
const init = initialStates[n.id];
|
|
if (init) {
|
|
return { ...n, x: init.x + dx, y: init.y + dy };
|
|
}
|
|
return n;
|
|
});
|
|
|
|
// Update Marks
|
|
next.marks = prev.marks.map(m => {
|
|
const init = initialStates[m.id];
|
|
if (init) {
|
|
return { ...m, x: init.x + dx, y: init.y + dy };
|
|
}
|
|
return m;
|
|
});
|
|
|
|
// Update Magnets
|
|
next.magnets = prev.magnets.map(m => {
|
|
const init = initialStates[m.id];
|
|
if (init) {
|
|
const newM = { ...m };
|
|
newM.p1 = { x: init.p1.x + dx, y: init.p1.y + dy };
|
|
newM.p2 = { x: init.p2.x + dx, y: init.p2.y + dy };
|
|
if (init.controlPoint && newM.controlPoint) {
|
|
newM.controlPoint = { x: init.controlPoint.x + dx, y: init.controlPoint.y + dy };
|
|
}
|
|
return newM;
|
|
}
|
|
return m;
|
|
});
|
|
|
|
return next;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = (e: React.MouseEvent) => {
|
|
setIsPanning(false);
|
|
setDragHandle(null);
|
|
setDragTarget(null);
|
|
|
|
// Box Selection End
|
|
if (selectionBox) {
|
|
const minX = Math.min(selectionBox.start.x, selectionBox.current.x);
|
|
const maxX = Math.max(selectionBox.start.x, selectionBox.current.x);
|
|
const minY = Math.min(selectionBox.start.y, selectionBox.current.y);
|
|
const maxY = Math.max(selectionBox.start.y, selectionBox.current.y);
|
|
|
|
// Avoid accidental clicks being box selections
|
|
if (maxX - minX > 5 || maxY - minY > 5) {
|
|
const newSelection = new Set<string>();
|
|
if (e.shiftKey || e.ctrlKey) {
|
|
selectedItemIds.forEach(id => newSelection.add(id));
|
|
}
|
|
|
|
// Find items in box
|
|
mapData.nodes.forEach(n => {
|
|
if (n.x >= minX && n.x <= maxX && n.y >= minY && n.y <= maxY) newSelection.add(n.id);
|
|
});
|
|
mapData.marks.forEach(m => {
|
|
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) newSelection.add(m.id);
|
|
});
|
|
mapData.magnets.forEach(m => {
|
|
// Simple center point check for magnets for now
|
|
const cx = (m.p1.x + m.p2.x) / 2;
|
|
const cy = (m.p1.y + m.p2.y) / 2;
|
|
if (cx >= minX && cx <= maxX && cy >= minY && cy <= maxY) newSelection.add(m.id);
|
|
});
|
|
|
|
setSelectedItemIds(newSelection);
|
|
}
|
|
setSelectionBox(null);
|
|
}
|
|
|
|
// Finish DRAW_LINE
|
|
if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) {
|
|
const { x, y } = mouseWorldPosRef.current;
|
|
const existingNode = mapData.nodes.find(n => getDistance(n, { x, y }) < 20);
|
|
if (existingNode && existingNode.id !== startNodeId) {
|
|
const edgeExists = mapData.edges.some(e =>
|
|
(e.from === startNodeId && e.to === existingNode.id) ||
|
|
(e.from === existingNode.id && e.to === startNodeId)
|
|
);
|
|
if (!edgeExists) {
|
|
const newEdge: MapEdge = {
|
|
id: crypto.randomUUID(),
|
|
from: startNodeId,
|
|
to: existingNode.id
|
|
};
|
|
setMapData(prev => ({ ...prev, edges: [...prev.edges, newEdge] }));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finish DRAW_MAGNET_STRAIGHT
|
|
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) {
|
|
const p1 = dragStartPos;
|
|
const p2 = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
|
if (getDistance(p1, p2) > 5) {
|
|
const newMag: MagnetLine = {
|
|
id: crypto.randomUUID(),
|
|
type: 'STRAIGHT',
|
|
p1,
|
|
p2
|
|
};
|
|
setMapData(prev => ({ ...prev, magnets: [...prev.magnets, newMag] }));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
setDragStartPos(null);
|
|
setStartNodeId(null);
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (contextMenu) {
|
|
setContextMenu(null);
|
|
return;
|
|
}
|
|
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const screenX = e.clientX - rect.left;
|
|
const screenY = e.clientY - rect.top;
|
|
|
|
if (e.button === 1 || (e.button === 0 && isSpacePressedRef.current)) {
|
|
setIsPanning(true);
|
|
return;
|
|
}
|
|
|
|
const { x, y } = screenToWorld(screenX, screenY);
|
|
const worldPos = { x, y };
|
|
|
|
if (activeTool === ToolType.ERASER) {
|
|
const hits = getObjectsAt(x, y);
|
|
if (hits.length === 1) {
|
|
deleteObject(hits[0]);
|
|
} else if (hits.length > 1) {
|
|
setContextMenu({ x: screenX, y: screenY, items: hits });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.ADD_RFID) {
|
|
// Smart ID Generation
|
|
const ids = mapData.nodes.map(n => parseInt(n.id)).filter(n => !isNaN(n));
|
|
const maxId = ids.length > 0 ? Math.max(...ids) : 0;
|
|
const newId = (maxId + 1).toString();
|
|
|
|
const newNode: MapNode = {
|
|
id: newId,
|
|
x: worldPos.x,
|
|
y: worldPos.y,
|
|
type: NodeType.Normal,
|
|
name: '',
|
|
rfidId: '0000', // Default RFID
|
|
connectedNodes: [],
|
|
|
|
// Defaults
|
|
stationType: 0,
|
|
speedLimit: 0,
|
|
canDocking: false,
|
|
dockDirection: 0,
|
|
canTurnLeft: false,
|
|
canTurnRight: false,
|
|
disableCross: false,
|
|
isActive: true,
|
|
nodeTextForeColor: '#FFFFFF',
|
|
nodeTextFontSize: 12
|
|
};
|
|
setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] }));
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.SELECT) {
|
|
// Check handles (Only if SINGLE item selected for now, to avoid complexity)
|
|
const worldTolerance = 15 / viewRef.current.scale;
|
|
|
|
if (selectedItemIds.size === 1) {
|
|
const singleId = Array.from(selectedItemIds)[0];
|
|
const selectedMag = mapData.magnets.find(m => m.id === singleId);
|
|
if (selectedMag) {
|
|
if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; }
|
|
if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); return; }
|
|
if (selectedMag.type === 'CURVE' && selectedMag.controlPoint && getDistance(worldPos, selectedMag.controlPoint) < worldTolerance) { setDragHandle('control'); return; }
|
|
}
|
|
const selectedMark = mapData.marks.find(m => m.id === singleId);
|
|
if (selectedMark) {
|
|
const rad = (selectedMark.rotation * Math.PI) / 180;
|
|
const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25;
|
|
const p1 = { x: selectedMark.x - dx, y: selectedMark.y - dy };
|
|
const p2 = { x: selectedMark.x + dx, y: selectedMark.y + dy };
|
|
if (getDistance(worldPos, p1) < worldTolerance) { setDragHandle('mark_p1'); return; }
|
|
if (getDistance(worldPos, p2) < worldTolerance) { setDragHandle('mark_p2'); return; }
|
|
}
|
|
}
|
|
|
|
// AGV
|
|
const agvDist = getDistance(worldPos, agvState);
|
|
if (agvDist < 30) {
|
|
setDragTarget({ type: 'AGV', startMouse: worldPos, initialAgvState: { x: agvState.x, y: agvState.y } });
|
|
setSelectedItemIds(new Set());
|
|
return;
|
|
}
|
|
|
|
// Map Objects
|
|
const hits = getObjectsAt(x, y);
|
|
const movableHits = hits.filter(h => h.type !== 'EDGE');
|
|
|
|
if (movableHits.length > 0) {
|
|
const hit = movableHits[0];
|
|
const isMultiSelect = e.shiftKey || e.ctrlKey;
|
|
|
|
let newSelection = new Set(selectedItemIds);
|
|
|
|
if (isMultiSelect) {
|
|
if (newSelection.has(hit.id)) newSelection.delete(hit.id);
|
|
else newSelection.add(hit.id);
|
|
} else {
|
|
if (!newSelection.has(hit.id)) {
|
|
newSelection = new Set([hit.id]);
|
|
}
|
|
// If already selected, keep selection (to allow drag)
|
|
}
|
|
setSelectedItemIds(newSelection);
|
|
|
|
// Prepare Multi-Drag
|
|
const initialStates: Record<string, any> = {};
|
|
newSelection.forEach(id => {
|
|
const n = mapData.nodes.find(o => o.id === id);
|
|
if (n) initialStates[id] = { x: n.x, y: n.y };
|
|
const m = mapData.marks.find(o => o.id === id);
|
|
if (m) initialStates[id] = { x: m.x, y: m.y };
|
|
const mag = mapData.magnets.find(o => o.id === id);
|
|
if (mag) initialStates[id] = JSON.parse(JSON.stringify(mag));
|
|
});
|
|
|
|
setDragTarget({ type: 'MULTI', startMouse: worldPos, initialStates });
|
|
} else {
|
|
// Click on empty space
|
|
if (!e.shiftKey && !e.ctrlKey) {
|
|
setSelectedItemIds(new Set());
|
|
}
|
|
// Start Box Selection
|
|
setSelectionBox({ start: worldPos, current: worldPos });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.DRAW_MAGNET_CURVE) {
|
|
if (curvePhase === 0) {
|
|
const start = getSnappedPos(x, y);
|
|
setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start });
|
|
setCurvePhase(1);
|
|
} else if (curvePhase === 1) {
|
|
// Set Endpoint
|
|
const end = getSnappedPos(x, y);
|
|
setTempMagnet(prev => prev ? { ...prev, p2: end } : null);
|
|
setCurvePhase(2);
|
|
} else if (curvePhase === 2) {
|
|
// Finish curve
|
|
if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) {
|
|
const finalMagnet: MagnetLine = {
|
|
id: tempMagnet.id!,
|
|
type: 'CURVE',
|
|
p1: tempMagnet.p1,
|
|
p2: tempMagnet.p2,
|
|
controlPoint: mouseWorldPosRef.current
|
|
};
|
|
setMapData(prev => ({ ...prev, magnets: [...prev.magnets, finalMagnet] }));
|
|
setTempMagnet(null);
|
|
setCurvePhase(0);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.DRAW_LINE) {
|
|
const hits = getObjectsAt(x, y);
|
|
const nodeHit = hits.find(h => h.type === 'NODE');
|
|
if (nodeHit) {
|
|
const n = mapData.nodes.find(n => n.id === nodeHit.id);
|
|
if (n) {
|
|
setDragStartPos({ x: n.x, y: n.y });
|
|
setStartNodeId(n.id);
|
|
}
|
|
} else {
|
|
const snapped = getSnappedPos(x, y);
|
|
const newNode: MapNode = {
|
|
id: `N${Date.now()}`,
|
|
x: snapped.x, y: snapped.y,
|
|
type: 0,
|
|
name: "",
|
|
rfidId: "",
|
|
connectedNodes: []
|
|
};
|
|
setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT) {
|
|
const snapped = getSnappedPos(x, y);
|
|
setDragStartPos(snapped);
|
|
return;
|
|
}
|
|
|
|
if (activeTool === ToolType.ADD_MARK) {
|
|
const snapped = getSnappedPos(x, y);
|
|
const newMark: FloorMark = {
|
|
id: crypto.randomUUID(),
|
|
x: snapped.x,
|
|
y: snapped.y,
|
|
rotation: 0
|
|
};
|
|
setMapData(prev => ({ ...prev, marks: [...prev.marks, newMark] }));
|
|
return;
|
|
}
|
|
|
|
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className={`absolute inset-0 bg-gray-900 overflow-hidden ${isPanning ? 'cursor-grabbing' : 'cursor-crosshair'}`} tabIndex={0}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
onWheel={handleWheel}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => setIsPanning(false)}
|
|
className="block bg-gray-950 outline-none w-full h-full"
|
|
/>
|
|
|
|
{/* HUD Info */}
|
|
<div className="absolute top-2 left-2 pointer-events-none text-xs text-gray-500 bg-gray-900/50 p-1 rounded backdrop-blur-sm">
|
|
Pos: {Math.round(agvState.x)}, {Math.round(agvState.y)} | Rot: {Math.round(agvState.rotation)}°
|
|
<span className="ml-2 text-gray-400">| Zoom: {Math.round(viewRef.current.scale * 100)}%</span>
|
|
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 1 && <span className="text-blue-400 ml-2">Drag to set Endpoint</span>}
|
|
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && <span className="text-yellow-400 ml-2">Move mouse to bend curve, Click to finish</span>}
|
|
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-70">
|
|
<Move size={10} /> <span>Middle Click / Space+Drag to Pan</span>
|
|
{selectedItemIds.size > 0 && <span className="text-yellow-500 font-bold ml-2">{selectedItemIds.size} Object(s) Selected</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Context Menu for Eraser */}
|
|
{contextMenu && (
|
|
<div
|
|
className="absolute z-10 bg-gray-800 border border-gray-600 rounded shadow-lg p-1 min-w-[150px]"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
>
|
|
<div className="flex items-center justify-between px-2 py-1 border-b border-gray-700 text-xs font-bold text-gray-400">
|
|
<span>Select object to delete</span>
|
|
<button onClick={() => setContextMenu(null)} className="hover:text-white"><X size={12} /></button>
|
|
</div>
|
|
<ul className="text-xs">
|
|
{contextMenu.items.map((item, idx) => (
|
|
<li
|
|
key={`${item.type}-${item.id}-${idx}`}
|
|
onClick={() => deleteObject(item)}
|
|
className="px-2 py-1.5 hover:bg-red-900/50 hover:text-red-200 cursor-pointer flex items-center gap-2"
|
|
>
|
|
<Trash2 size={12} />
|
|
<span>{item.name}</span>
|
|
<span className="text-[10px] text-gray-500 ml-auto">{Math.round(item.dist)}px</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SimulationCanvas; |