feat: add interactive wire segment editing and utility functions
This commit is contained in:
parent
217736c7cd
commit
a8bb0b6ad9
11
README.md
11
README.md
|
|
@ -14,6 +14,16 @@ Local Arduino emulator with code editor and visual simulator.
|
||||||
- ⏳ Full emulation with avr8js (in progress)
|
- ⏳ Full emulation with avr8js (in progress)
|
||||||
- ⏳ SQLite persistence (coming soon)
|
- ⏳ SQLite persistence (coming soon)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<img src="doc/img1.png" alt="Arduino Emulator - Editor and Simulator" width="800"/>
|
||||||
|
|
||||||
|
*Arduino emulator with Monaco code editor and visual simulator with wokwi-elements*
|
||||||
|
|
||||||
|
<img src="doc/img2.png" alt="Arduino Emulator - Component Properties and Wire Editing" width="800"/>
|
||||||
|
|
||||||
|
*Interactive component properties dialog and segment-based wire editing*
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### 1. Node.js
|
### 1. Node.js
|
||||||
|
|
@ -245,3 +255,4 @@ MIT
|
||||||
- [wokwi-elements](https://github.com/wokwi/wokwi-elements) - Web components
|
- [wokwi-elements](https://github.com/wokwi/wokwi-elements) - Web components
|
||||||
- [arduino-cli](https://github.com/arduino/arduino-cli) - Arduino compiler
|
- [arduino-cli](https://github.com/arduino/arduino-cli) - Arduino compiler
|
||||||
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - Code editor
|
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - Code editor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"generatedAt": "2026-03-03T20:10:47.821Z",
|
"generatedAt": "2026-03-04T00:10:03.051Z",
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"id": "arduino-mega",
|
"id": "arduino-mega",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
/**
|
/**
|
||||||
* WireRenderer Component
|
* WireRenderer Component
|
||||||
*
|
*
|
||||||
* Renders an individual wire with the following features:
|
* Renders wires with segment-based editing:
|
||||||
* - Invisible thick path for easy clicking
|
* - Click to select wire
|
||||||
* - Visible colored path based on signal type
|
* - Hover over segments to see drag handles
|
||||||
* - Endpoint markers (circles)
|
* - Drag horizontal segments up/down
|
||||||
* - Control points when selected (Phase 2)
|
* - Drag vertical segments left/right
|
||||||
* - Dashed line for invalid connections (Phase 3)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useCallback, useState, useRef } from 'react';
|
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||||
import type { Wire } from '../../types/wire';
|
import type { Wire } from '../../types/wire';
|
||||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||||
import { generateWirePath } from '../../utils/wirePathGenerator';
|
import { generateWirePath } from '../../utils/wirePathGenerator';
|
||||||
|
import {
|
||||||
|
computeSegments,
|
||||||
|
findSegmentUnderCursor,
|
||||||
|
getPathPoints,
|
||||||
|
generateOrthogonalPoints,
|
||||||
|
updateOrthogonalPointsForSegmentDrag,
|
||||||
|
orthogonalPointsToControlPoints,
|
||||||
|
type WireSegment,
|
||||||
|
} from '../../utils/wireSegments';
|
||||||
|
|
||||||
interface WireRendererProps {
|
interface WireRendererProps {
|
||||||
wire: Wire;
|
wire: Wire;
|
||||||
|
|
@ -21,14 +29,38 @@ interface WireRendererProps {
|
||||||
|
|
||||||
export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected }) => {
|
export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected }) => {
|
||||||
const { setSelectedWire, updateWire } = useSimulatorStore();
|
const { setSelectedWire, updateWire } = useSimulatorStore();
|
||||||
const [draggedCPId, setDraggedCPId] = useState<string | null>(null);
|
const [hoveredSegment, setHoveredSegment] = useState<WireSegment | null>(null);
|
||||||
|
const [dragState, setDragState] = useState<{
|
||||||
|
segment: WireSegment;
|
||||||
|
startMousePos: { x: number; y: number };
|
||||||
|
originalOrthoPoints: Array<{ x: number; y: number }>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Local preview path during drag (for smooth performance)
|
||||||
|
const [previewOrthoPoints, setPreviewOrthoPoints] = useState<Array<{ x: number; y: number }> | null>(null);
|
||||||
|
|
||||||
const svgRef = useRef<SVGGElement>(null);
|
const svgRef = useRef<SVGGElement>(null);
|
||||||
|
|
||||||
// Generate SVG path (memoized for performance)
|
// Generate SVG path (memoized for performance)
|
||||||
|
// Use preview points during drag, actual wire points otherwise
|
||||||
const path = useMemo(() => {
|
const path = useMemo(() => {
|
||||||
|
if (previewOrthoPoints) {
|
||||||
|
// Generate path from preview points during drag
|
||||||
|
let pathD = `M ${previewOrthoPoints[0].x} ${previewOrthoPoints[0].y}`;
|
||||||
|
for (let i = 1; i < previewOrthoPoints.length; i++) {
|
||||||
|
pathD += ` L ${previewOrthoPoints[i].x} ${previewOrthoPoints[i].y}`;
|
||||||
|
}
|
||||||
|
return pathD;
|
||||||
|
}
|
||||||
return generateWirePath(wire);
|
return generateWirePath(wire);
|
||||||
}, [wire.start.x, wire.start.y, wire.end.x, wire.end.y, wire.controlPoints]);
|
}, [wire, previewOrthoPoints]);
|
||||||
|
|
||||||
|
// Compute segments (memoized)
|
||||||
|
const segments = useMemo(() => {
|
||||||
|
return computeSegments(wire);
|
||||||
|
}, [wire]);
|
||||||
|
|
||||||
|
// Handle wire selection
|
||||||
const handleWireClick = useCallback(
|
const handleWireClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -37,38 +69,126 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
||||||
[wire.id, setSelectedWire]
|
[wire.id, setSelectedWire]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleControlPointMouseDown = useCallback(
|
// Handle segment hover
|
||||||
(cpId: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDraggedCPId(cpId);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!draggedCPId || !svgRef.current) return;
|
if (dragState) {
|
||||||
|
// Handle dragging - use local state for smooth updates
|
||||||
const svg = svgRef.current.ownerSVGElement;
|
const svg = svgRef.current?.ownerSVGElement;
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
// Get SVG bounding rect and convert mouse position to SVG coordinates
|
|
||||||
const svgRect = svg.getBoundingClientRect();
|
const svgRect = svg.getBoundingClientRect();
|
||||||
const x = e.clientX - svgRect.left;
|
const mouseX = e.clientX - svgRect.left;
|
||||||
const y = e.clientY - svgRect.top;
|
const mouseY = e.clientY - svgRect.top;
|
||||||
|
|
||||||
const updatedControlPoints = wire.controlPoints.map((cp) =>
|
const { segment, startMousePos, originalOrthoPoints } = dragState;
|
||||||
cp.id === draggedCPId ? { ...cp, x, y } : cp
|
|
||||||
|
// Calculate offset perpendicular to segment
|
||||||
|
let offset = 0;
|
||||||
|
if (segment.orientation === 'horizontal') {
|
||||||
|
offset = mouseY - startMousePos.y;
|
||||||
|
} else {
|
||||||
|
offset = mouseX - startMousePos.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grid snapping during drag for smooth movement
|
||||||
|
// Grid snapping will be applied on mouse up
|
||||||
|
|
||||||
|
// Update orthogonal points (local preview)
|
||||||
|
const newOrthoPoints = updateOrthogonalPointsForSegmentDrag(
|
||||||
|
originalOrthoPoints,
|
||||||
|
segment,
|
||||||
|
offset
|
||||||
);
|
);
|
||||||
|
|
||||||
updateWire(wire.id, { controlPoints: updatedControlPoints });
|
// Update preview state (doesn't touch the store)
|
||||||
|
setPreviewOrthoPoints(newOrthoPoints);
|
||||||
|
} else if (isSelected) {
|
||||||
|
// Update hovered segment
|
||||||
|
const svg = svgRef.current?.ownerSVGElement;
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const svgRect = svg.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - svgRect.left;
|
||||||
|
const mouseY = e.clientY - svgRect.top;
|
||||||
|
|
||||||
|
const segment = findSegmentUnderCursor(segments, mouseX, mouseY);
|
||||||
|
setHoveredSegment(segment);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[draggedCPId, wire.id, wire.controlPoints, updateWire]
|
[dragState, isSelected, segments, wire, updateWire]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSegmentMouseDown = useCallback(
|
||||||
|
(segment: WireSegment, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const svg = svgRef.current?.ownerSVGElement;
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const svgRect = svg.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - svgRect.left;
|
||||||
|
const mouseY = e.clientY - svgRect.top;
|
||||||
|
|
||||||
|
// Get current orthogonal points
|
||||||
|
const pathPoints = getPathPoints(wire);
|
||||||
|
const orthoPoints = generateOrthogonalPoints(pathPoints);
|
||||||
|
|
||||||
|
setDragState({
|
||||||
|
segment,
|
||||||
|
startMousePos: { x: mouseX, y: mouseY },
|
||||||
|
originalOrthoPoints: orthoPoints,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[wire]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
setDraggedCPId(null);
|
if (dragState && previewOrthoPoints) {
|
||||||
}, []);
|
// Apply grid snapping to final position
|
||||||
|
const GRID_SIZE = 20;
|
||||||
|
const snappedPoints = previewOrthoPoints.map((p) => ({
|
||||||
|
x: Math.round(p.x / GRID_SIZE) * GRID_SIZE,
|
||||||
|
y: Math.round(p.y / GRID_SIZE) * GRID_SIZE,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Convert back to control points
|
||||||
|
const newControlPoints = orthogonalPointsToControlPoints(
|
||||||
|
snappedPoints,
|
||||||
|
wire.start,
|
||||||
|
wire.end
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update store only once at the end
|
||||||
|
updateWire(wire.id, { controlPoints: newControlPoints });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear drag state and preview
|
||||||
|
setDragState(null);
|
||||||
|
setPreviewOrthoPoints(null);
|
||||||
|
}, [dragState, previewOrthoPoints, wire, updateWire]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (!dragState) {
|
||||||
|
setHoveredSegment(null);
|
||||||
|
}
|
||||||
|
}, [dragState]);
|
||||||
|
|
||||||
|
// Update cursor based on hovered segment
|
||||||
|
useEffect(() => {
|
||||||
|
const svg = svgRef.current?.ownerSVGElement;
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
if (dragState) {
|
||||||
|
svg.style.cursor =
|
||||||
|
dragState.segment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize';
|
||||||
|
} else if (hoveredSegment) {
|
||||||
|
svg.style.cursor =
|
||||||
|
hoveredSegment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize';
|
||||||
|
} else {
|
||||||
|
svg.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
}, [hoveredSegment, dragState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
|
@ -76,7 +196,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
||||||
className="wire-group"
|
className="wire-group"
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
{/* Invisible thick path for easier clicking */}
|
{/* Invisible thick path for easier clicking */}
|
||||||
<path
|
<path
|
||||||
|
|
@ -91,11 +211,12 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
||||||
{/* Visible wire path */}
|
{/* Visible wire path */}
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={path}
|
||||||
stroke={wire.isValid ? wire.color : '#ff4444'} // Red for invalid (Phase 3)
|
stroke={wire.isValid ? wire.color : '#ff4444'}
|
||||||
strokeWidth="2"
|
strokeWidth={isSelected ? '3' : '2'}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray={wire.isValid ? undefined : '5,5'} // Dashed for invalid
|
strokeDasharray={wire.isValid ? undefined : '5,5'}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
|
opacity={isSelected ? '1' : '0.8'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Endpoint markers */}
|
{/* Endpoint markers */}
|
||||||
|
|
@ -106,19 +227,13 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
||||||
fill={wire.color}
|
fill={wire.color}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle cx={wire.end.x} cy={wire.end.y} r="3" fill={wire.color} style={{ pointerEvents: 'none' }} />
|
||||||
cx={wire.end.x}
|
|
||||||
cy={wire.end.y}
|
|
||||||
r="3"
|
|
||||||
fill={wire.color}
|
|
||||||
style={{ pointerEvents: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Selection indicator */}
|
{/* Selection indicator */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={path}
|
||||||
stroke="#00ffff" // Cyan highlight for selected wire
|
stroke="#00ffff"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeDasharray="10,5"
|
strokeDasharray="10,5"
|
||||||
|
|
@ -127,24 +242,55 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Control points - draggable when wire is selected */}
|
{/* Segment interaction overlays - only when selected */}
|
||||||
{isSelected && wire.controlPoints.length > 0 && (
|
{isSelected &&
|
||||||
|
segments.map((segment) => (
|
||||||
|
<g key={segment.id}>
|
||||||
|
{/* Invisible thick hitbox for easier interaction */}
|
||||||
|
<line
|
||||||
|
x1={segment.startPoint.x}
|
||||||
|
y1={segment.startPoint.y}
|
||||||
|
x2={segment.endPoint.x}
|
||||||
|
y2={segment.endPoint.y}
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth="16"
|
||||||
|
style={{
|
||||||
|
cursor:
|
||||||
|
segment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize',
|
||||||
|
pointerEvents: 'stroke',
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleSegmentMouseDown(segment, e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual drag handle at midpoint when hovering */}
|
||||||
|
{(hoveredSegment?.id === segment.id || dragState?.segment.id === segment.id) && (
|
||||||
<>
|
<>
|
||||||
{wire.controlPoints.map((cp) => (
|
{/* Highlight the segment */}
|
||||||
|
<line
|
||||||
|
x1={segment.startPoint.x}
|
||||||
|
y1={segment.startPoint.y}
|
||||||
|
x2={segment.endPoint.x}
|
||||||
|
y2={segment.endPoint.y}
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth="4"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drag handle circle */}
|
||||||
<circle
|
<circle
|
||||||
key={cp.id}
|
cx={segment.midPoint.x}
|
||||||
cx={cp.x}
|
cy={segment.midPoint.y}
|
||||||
cy={cp.y}
|
r="5"
|
||||||
r="6"
|
fill="#8b5cf6"
|
||||||
fill={draggedCPId === cp.id ? '#a78bfa' : '#8b5cf6'} // Lighter purple when dragging
|
|
||||||
stroke="white"
|
stroke="white"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
style={{ cursor: 'move', pointerEvents: 'all' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
onMouseDown={(e) => handleControlPointMouseDown(cp.id, e)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
/**
|
||||||
|
* Wire Segment Utilities
|
||||||
|
*
|
||||||
|
* Handles computation and manipulation of wire segments for interactive editing.
|
||||||
|
* Segments are the straight horizontal/vertical lines between path points.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Wire, WireControlPoint } from '../types/wire';
|
||||||
|
|
||||||
|
export interface WireSegment {
|
||||||
|
id: string;
|
||||||
|
startPoint: { x: number; y: number };
|
||||||
|
endPoint: { x: number; y: number };
|
||||||
|
orientation: 'horizontal' | 'vertical';
|
||||||
|
midPoint: { x: number; y: number };
|
||||||
|
length: number;
|
||||||
|
startIndex: number; // Index in orthoPoints array
|
||||||
|
endIndex: number; // Index in orthoPoints array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all path points (start + control points + end)
|
||||||
|
*/
|
||||||
|
export function getPathPoints(wire: Wire): Array<{ x: number; y: number }> {
|
||||||
|
const points: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
|
points.push({ x: wire.start.x, y: wire.start.y });
|
||||||
|
|
||||||
|
for (const cp of wire.controlPoints) {
|
||||||
|
points.push({ x: cp.x, y: cp.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({ x: wire.end.x, y: wire.end.y });
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate orthogonal path points from control points
|
||||||
|
* Converts diagonal connections to L-shapes (horizontal then vertical or vice versa)
|
||||||
|
*/
|
||||||
|
export function generateOrthogonalPoints(
|
||||||
|
points: Array<{ x: number; y: number }>
|
||||||
|
): Array<{ x: number; y: number }> {
|
||||||
|
const result: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const current = points[i];
|
||||||
|
const next = points[i + 1];
|
||||||
|
|
||||||
|
result.push(current);
|
||||||
|
|
||||||
|
// If points are not aligned, add intermediate point
|
||||||
|
if (current.x !== next.x && current.y !== next.y) {
|
||||||
|
const dx = Math.abs(next.x - current.x);
|
||||||
|
const dy = Math.abs(next.y - current.y);
|
||||||
|
|
||||||
|
if (dx > dy) {
|
||||||
|
// Go horizontal first
|
||||||
|
result.push({ x: next.x, y: current.y });
|
||||||
|
} else {
|
||||||
|
// Go vertical first
|
||||||
|
result.push({ x: current.x, y: next.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(points[points.length - 1]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute all segments from a wire
|
||||||
|
*/
|
||||||
|
export function computeSegments(wire: Wire): WireSegment[] {
|
||||||
|
const pathPoints = getPathPoints(wire);
|
||||||
|
const orthoPoints = generateOrthogonalPoints(pathPoints);
|
||||||
|
const segments: WireSegment[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < orthoPoints.length - 1; i++) {
|
||||||
|
const start = orthoPoints[i];
|
||||||
|
const end = orthoPoints[i + 1];
|
||||||
|
|
||||||
|
// Skip zero-length segments
|
||||||
|
if (start.x === end.x && start.y === end.y) continue;
|
||||||
|
|
||||||
|
const orientation = start.y === end.y ? 'horizontal' : 'vertical';
|
||||||
|
const length =
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? Math.abs(end.x - start.x)
|
||||||
|
: Math.abs(end.y - start.y);
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
id: `${wire.id}-seg-${i}`,
|
||||||
|
startPoint: start,
|
||||||
|
endPoint: end,
|
||||||
|
orientation,
|
||||||
|
midPoint: {
|
||||||
|
x: (start.x + end.x) / 2,
|
||||||
|
y: (start.y + end.y) / 2,
|
||||||
|
},
|
||||||
|
length,
|
||||||
|
startIndex: i,
|
||||||
|
endIndex: i + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which segment is under the cursor
|
||||||
|
*/
|
||||||
|
export function findSegmentUnderCursor(
|
||||||
|
segments: WireSegment[],
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
threshold: number = 8 // 8px tolerance
|
||||||
|
): WireSegment | null {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.orientation === 'horizontal') {
|
||||||
|
const minX = Math.min(segment.startPoint.x, segment.endPoint.x);
|
||||||
|
const maxX = Math.max(segment.startPoint.x, segment.endPoint.x);
|
||||||
|
const lineY = segment.startPoint.y;
|
||||||
|
|
||||||
|
if (
|
||||||
|
mouseX >= minX &&
|
||||||
|
mouseX <= maxX &&
|
||||||
|
Math.abs(mouseY - lineY) <= threshold
|
||||||
|
) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const minY = Math.min(segment.startPoint.y, segment.endPoint.y);
|
||||||
|
const maxY = Math.max(segment.startPoint.y, segment.endPoint.y);
|
||||||
|
const lineX = segment.startPoint.x;
|
||||||
|
|
||||||
|
if (
|
||||||
|
mouseY >= minY &&
|
||||||
|
mouseY <= maxY &&
|
||||||
|
Math.abs(mouseX - lineX) <= threshold
|
||||||
|
) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update orthogonal points when dragging a segment
|
||||||
|
*/
|
||||||
|
export function updateOrthogonalPointsForSegmentDrag(
|
||||||
|
orthoPoints: Array<{ x: number; y: number }>,
|
||||||
|
segment: WireSegment,
|
||||||
|
offset: number
|
||||||
|
): Array<{ x: number; y: number }> {
|
||||||
|
const newPoints = orthoPoints.map((p) => ({ ...p }));
|
||||||
|
|
||||||
|
const { startIndex, endIndex, orientation } = segment;
|
||||||
|
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
// Move horizontal segment up/down (change Y)
|
||||||
|
newPoints[startIndex].y += offset;
|
||||||
|
newPoints[endIndex].y += offset;
|
||||||
|
} else {
|
||||||
|
// Move vertical segment left/right (change X)
|
||||||
|
newPoints[startIndex].x += offset;
|
||||||
|
newPoints[endIndex].x += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert orthogonal points back to control points
|
||||||
|
* Removes start/end points and intermediate points that are redundant
|
||||||
|
*/
|
||||||
|
export function orthogonalPointsToControlPoints(
|
||||||
|
orthoPoints: Array<{ x: number; y: number }>,
|
||||||
|
start: { x: number; y: number },
|
||||||
|
end: { x: number; y: number }
|
||||||
|
): WireControlPoint[] {
|
||||||
|
// Remove first and last points (those are start/end endpoints)
|
||||||
|
const innerPoints = orthoPoints.slice(1, -1);
|
||||||
|
|
||||||
|
// Remove redundant points (those that are collinear with neighbors)
|
||||||
|
const controlPoints: WireControlPoint[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < innerPoints.length; i++) {
|
||||||
|
const current = innerPoints[i];
|
||||||
|
const prev = i === 0 ? start : innerPoints[i - 1];
|
||||||
|
const next = i === innerPoints.length - 1 ? end : innerPoints[i + 1];
|
||||||
|
|
||||||
|
// Check if current point is a corner (changes direction)
|
||||||
|
const isCorner =
|
||||||
|
(prev.x === current.x && current.y === next.y) ||
|
||||||
|
(prev.y === current.y && current.x === next.x);
|
||||||
|
|
||||||
|
if (isCorner) {
|
||||||
|
controlPoints.push({
|
||||||
|
id: `cp-${Date.now()}-${i}`,
|
||||||
|
x: current.x,
|
||||||
|
y: current.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return controlPoints;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue