From fe89d06787ae826b9537508271e453b2d8a5d56f Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 14 Mar 2026 23:09:32 -0300 Subject: [PATCH] feat: implement segment dragging functionality in simulator canvas --- .../components/simulator/SimulatorCanvas.tsx | 134 +++++++++++------- .../src/components/simulator/WireLayer.tsx | 48 ++++++- .../src/components/simulator/WireRenderer.tsx | 5 +- frontend/src/utils/wireHitDetection.ts | 85 +++++++++++ 4 files changed, 209 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index ec31bb9..0b31860 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -6,6 +6,7 @@ import { DynamicComponent, createComponentFromMetadata } from '../DynamicCompone import { ComponentRegistry } from '../../services/ComponentRegistry'; import { PinSelector } from './PinSelector'; import { WireLayer } from './WireLayer'; +import type { SegmentHandle } from './WireLayer'; import { BoardOnCanvas } from './BoardOnCanvas'; import { BoardPickerModal } from './BoardPickerModal'; import { PartSimulationRegistry } from '../../simulation/parts'; @@ -14,8 +15,11 @@ import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping' import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils'; import { findWireNearPoint, - findSegmentNearPoint, - computeDragWaypoints, + getRenderedPoints, + getRenderedSegments, + moveSegment, + renderedToWaypoints, + renderedPointsToPath, } from '../../utils/wireHitDetection'; import type { ComponentMetadata } from '../../types/component-metadata'; import type { BoardKind } from '../../types/board'; @@ -125,20 +129,35 @@ export const SimulatorCanvas = () => { // Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues) const [hoveredWireId, setHoveredWireId] = useState(null); - const [wireDragPreview, setWireDragPreview] = useState<{ + const [segmentDragPreview, setSegmentDragPreview] = useState<{ wireId: string; - waypoints: { x: number; y: number }[]; + overridePath: string; } | null>(null); - const wireInteractionRef = useRef<{ + const segmentDragRef = useRef<{ wireId: string; - startWorld: { x: number; y: number }; - segment: import('../../utils/wireHitDetection').RenderedSegment | null; - originalWaypoints: { x: number; y: number }[]; + segIndex: number; + axis: 'horizontal' | 'vertical'; + renderedPts: { x: number; y: number }[]; isDragging: boolean; } | null>(null); + /** Set to true during mouseup if a segment drag committed, so onClick can skip selection. */ + const segmentDragJustCommittedRef = useRef(false); const wiresRef = useRef(wires); wiresRef.current = wires; + // Compute midpoint handles for the selected wire's segments + const segmentHandles = React.useMemo(() => { + if (!selectedWireId) return []; + const wire = wires.find((w) => w.id === selectedWireId); + if (!wire) return []; + return getRenderedSegments(wire).map((seg, i) => ({ + segIndex: i, + axis: seg.axis, + mx: (seg.x1 + seg.x2) / 2, + my: (seg.y1 + seg.y2) / 2, + })); + }, [selectedWireId, wires]); + // Touch-specific state refs (for single-finger drag and pinch-to-zoom) const touchDraggedComponentIdRef = useRef(null); const touchDragOffsetRef = useRef({ x: 0, y: 0 }); @@ -616,27 +635,15 @@ export const SimulatorCanvas = () => { return; } - // Handle wire segment dragging - if (wireInteractionRef.current) { + // Handle segment handle dragging + if (segmentDragRef.current) { const world = toWorld(e.clientX, e.clientY); - const wi = wireInteractionRef.current; - - if (!wi.isDragging) { - const moved = Math.hypot(world.x - wi.startWorld.x, world.y - wi.startWorld.y); - if (moved > 4 / zoomRef.current) { - wi.isDragging = true; - } - } - - if (wi.isDragging && wi.segment) { - const newWaypoints = computeDragWaypoints( - wi.originalWaypoints, - wi.segment.storedPairIndex, - world.x, - world.y, - ); - setWireDragPreview({ wireId: wi.wireId, waypoints: newWaypoints }); - } + const sd = segmentDragRef.current; + sd.isDragging = true; + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + const overridePath = renderedPointsToPath(newPts); + setSegmentDragPreview({ wireId: sd.wireId, overridePath }); return; } @@ -657,14 +664,19 @@ export const SimulatorCanvas = () => { return; } - // Commit wire segment drag - if (wireInteractionRef.current) { - const wi = wireInteractionRef.current; - if (wi.isDragging && wireDragPreview) { - updateWire(wi.wireId, { waypoints: wireDragPreview.waypoints }); + // Commit segment handle drag + if (segmentDragRef.current) { + const sd = segmentDragRef.current; + if (sd.isDragging) { + segmentDragJustCommittedRef.current = true; + const world = toWorld(e.clientX, e.clientY); + const newValue = sd.axis === 'horizontal' ? world.y : world.x; + const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue); + updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) }); } - wireInteractionRef.current = null; - setWireDragPreview(null); + segmentDragRef.current = null; + setSegmentDragPreview(null); + return; } if (draggedComponentId) { @@ -688,7 +700,7 @@ export const SimulatorCanvas = () => { } }; - // Start panning on middle-click or right-click; wire drag on left-click near wire + // Start panning on middle-click or right-click const handleCanvasMouseDown = (e: React.MouseEvent) => { if (e.button === 1 || e.button === 2) { e.preventDefault(); @@ -699,25 +711,32 @@ export const SimulatorCanvas = () => { panX: panRef.current.x, panY: panRef.current.y, }; - } else if (e.button === 0 && !wireInProgress) { - // Check if clicking near a wire to start a drag interaction - const world = toWorld(e.clientX, e.clientY); - const threshold = 8 / zoomRef.current; - const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold); - if (wire) { - const segment = findSegmentNearPoint(wire, world.x, world.y, threshold); - wireInteractionRef.current = { - wireId: wire.id, - startWorld: world, - segment, - originalWaypoints: [...(wire.waypoints ?? [])], - isDragging: false, - }; - // Don't stopPropagation here — allow component drag detection to also run - } } }; + // Handle mousedown on a segment handle circle (called from WireLayer) + const handleHandleMouseDown = useCallback( + (e: React.MouseEvent, segIndex: number) => { + e.stopPropagation(); + e.preventDefault(); + if (!selectedWireId) return; + const wire = wiresRef.current.find((w) => w.id === selectedWireId); + if (!wire) return; + const segments = getRenderedSegments(wire); + const seg = segments[segIndex]; + if (!seg) return; + const expandedPts = getRenderedPoints(wire); + segmentDragRef.current = { + wireId: wire.id, + segIndex, + axis: seg.axis, + renderedPts: expandedPts, + isDragging: false, + }; + }, + [selectedWireId], + ); + // Zoom centered on cursor const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); @@ -1028,8 +1047,11 @@ export const SimulatorCanvas = () => { addWireWaypoint(world.x, world.y); return; } - // If a wire drag just finished, don't also select - if (wireInteractionRef.current?.isDragging) return; + // If a segment handle drag just finished, don't also select + if (segmentDragJustCommittedRef.current) { + segmentDragJustCommittedRef.current = false; + return; + } // Wire selection via canvas-level hit detection const world = toWorld(e.clientX, e.clientY); const threshold = 8 / zoomRef.current; @@ -1065,7 +1087,9 @@ export const SimulatorCanvas = () => { {/* Wire Layer - Renders below all components */} {/* All boards on canvas */} diff --git a/frontend/src/components/simulator/WireLayer.tsx b/frontend/src/components/simulator/WireLayer.tsx index b9111c5..7fdb0b1 100644 --- a/frontend/src/components/simulator/WireLayer.tsx +++ b/frontend/src/components/simulator/WireLayer.tsx @@ -3,12 +3,29 @@ import { useSimulatorStore } from '../../store/useSimulatorStore'; import { WireRenderer } from './WireRenderer'; import { WireInProgressRenderer } from './WireInProgressRenderer'; -interface WireLayerProps { - hoveredWireId: string | null; - wireDragPreview: { wireId: string; waypoints: { x: number; y: number }[] } | null; +export interface SegmentHandle { + segIndex: number; + axis: 'horizontal' | 'vertical'; + mx: number; // midpoint X + my: number; // midpoint Y } -export const WireLayer: React.FC = ({ hoveredWireId, wireDragPreview }) => { +interface WireLayerProps { + hoveredWireId: string | null; + /** Segment drag preview: overrides the path of a specific wire */ + segmentDragPreview: { wireId: string; overridePath: string } | null; + /** Handles to render for the selected wire */ + segmentHandles: SegmentHandle[]; + /** Called when user starts dragging a handle (passes segIndex) */ + onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void; +} + +export const WireLayer: React.FC = ({ + hoveredWireId, + segmentDragPreview, + segmentHandles, + onHandleMouseDown, +}) => { const wires = useSimulatorStore((s) => s.wires); const wireInProgress = useSimulatorStore((s) => s.wireInProgress); const selectedWireId = useSimulatorStore((s) => s.selectedWireId); @@ -24,7 +41,7 @@ export const WireLayer: React.FC = ({ hoveredWireId, wireDragPre height: '100%', overflow: 'visible', pointerEvents: 'none', - zIndex: 1, + zIndex: 20, }} > {wires.map((wire) => ( @@ -33,12 +50,29 @@ export const WireLayer: React.FC = ({ hoveredWireId, wireDragPre wire={wire} isSelected={wire.id === selectedWireId} isHovered={wire.id === hoveredWireId} - previewWaypoints={ - wireDragPreview?.wireId === wire.id ? wireDragPreview.waypoints : undefined + overridePath={ + segmentDragPreview?.wireId === wire.id + ? segmentDragPreview.overridePath + : undefined } /> ))} + {/* Segment handles for the selected wire */} + {segmentHandles.map((handle) => ( + onHandleMouseDown(e, handle.segIndex)} + /> + ))} + {wireInProgress && ( )} diff --git a/frontend/src/components/simulator/WireRenderer.tsx b/frontend/src/components/simulator/WireRenderer.tsx index 75aa59d..2ee8865 100644 --- a/frontend/src/components/simulator/WireRenderer.tsx +++ b/frontend/src/components/simulator/WireRenderer.tsx @@ -13,6 +13,8 @@ interface WireRendererProps { isHovered: boolean; /** Temporary waypoints used during drag preview */ previewWaypoints?: { x: number; y: number }[]; + /** Override the full SVG path string (used during segment drag preview) */ + overridePath?: string; } export const WireRenderer: React.FC = ({ @@ -20,9 +22,10 @@ export const WireRenderer: React.FC = ({ isSelected, isHovered, previewWaypoints, + overridePath, }) => { const waypoints = previewWaypoints ?? wire.waypoints; - const path = generateOrthogonalPath(wire.start, waypoints, wire.end); + const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end); if (!path) return null; diff --git a/frontend/src/utils/wireHitDetection.ts b/frontend/src/utils/wireHitDetection.ts index 5bf0b00..3be28a7 100644 --- a/frontend/src/utils/wireHitDetection.ts +++ b/frontend/src/utils/wireHitDetection.ts @@ -145,3 +145,88 @@ export function computeDragWaypoints( ...originalWaypoints.slice(storedPairIndex), ]; } + +/** + * Move an entire rendered segment perpendicularly. + * - horizontal segment → moves up/down (change Y of both endpoints) + * - vertical segment → moves left/right (change X of both endpoints) + * If the segment is the first or last, inserts connector points to keep + * the wire connected to its fixed start/end. + */ +export function moveSegment( + renderedPts: { x: number; y: number }[], + segIndex: number, + axis: 'horizontal' | 'vertical', + newValue: number, +): { x: number; y: number }[] { + const n = renderedPts.length; + const numSegs = n - 1; + const pts = renderedPts.map((p) => ({ ...p })); + + if (axis === 'horizontal') { + if (segIndex === 0 && numSegs > 0) { + // First segment: keep start fixed, insert connector + pts.splice(1, 0, { x: pts[0].x, y: newValue }, { x: pts[1].x, y: newValue }); + pts.splice(3, 1); // remove original pts[1] copy + } else if (segIndex === numSegs - 1 && numSegs > 0) { + // Last segment: keep end fixed, insert connector + const last = pts[n - 1]; + pts.splice(n - 1, 0, { x: pts[n - 2].x, y: newValue }, { x: last.x, y: newValue }); + } else { + pts[segIndex].y = newValue; + pts[segIndex + 1].y = newValue; + } + } else { + // vertical + if (segIndex === 0 && numSegs > 0) { + pts.splice(1, 0, { x: newValue, y: pts[0].y }, { x: newValue, y: pts[1].y }); + pts.splice(3, 1); + } else if (segIndex === numSegs - 1 && numSegs > 0) { + const last = pts[n - 1]; + pts.splice(n - 1, 0, { x: newValue, y: pts[n - 2].y }, { x: newValue, y: last.y }); + } else { + pts[segIndex].x = newValue; + pts[segIndex + 1].x = newValue; + } + } + + return pts; +} + +/** + * Convert a list of rendered (expanded) points back to wire waypoints. + * Waypoints are the interior corner/bend points (excludes start and end). + * Consecutive collinear points are collapsed so only actual corners remain. + */ +export function renderedToWaypoints( + renderedPts: { x: number; y: number }[], +): { x: number; y: number }[] { + if (renderedPts.length <= 2) return []; + + const waypoints: { x: number; y: number }[] = []; + for (let i = 1; i < renderedPts.length - 1; i++) { + const prev = renderedPts[i - 1]; + const curr = renderedPts[i]; + const next = renderedPts[i + 1]; + const d1x = Math.sign(curr.x - prev.x); + const d1y = Math.sign(curr.y - prev.y); + const d2x = Math.sign(next.x - curr.x); + const d2y = Math.sign(next.y - curr.y); + // Keep only direction-change points (actual corners) + if (d1x !== d2x || d1y !== d2y) { + waypoints.push({ x: curr.x, y: curr.y }); + } + } + return waypoints; +} + +/** + * Build an SVG path string from an ordered list of rendered points (straight segments). + */ +export function renderedPointsToPath(pts: { x: number; y: number }[]): string { + if (pts.length < 2) return ''; + return ( + `M ${pts[0].x} ${pts[0].y}` + + pts.slice(1).map((p) => ` L ${p.x} ${p.y}`).join('') + ); +}