import { useMemo, useRef, useState } from 'react'; import * as THREE from 'three'; import { Html, Line, useCursor } from '@react-three/drei'; import { useFrame } from '@react-three/fiber'; import type { ThreeEvent } from '@react-three/fiber'; import type { WorldConnector } from '../types'; import { useBuilder } from '../store/builderStore'; import { getWorldConnectors } from '../utils/connectors'; import { beginPartDrag, dragCtx } from '../utils/dragState'; /** * ConnectorHandles — grabbable spheres at every connector of the selected * parts. Dragging a handle moves the WHOLE part (and the rest of the * selection, rigidly) so the grabbed connector follows the cursor; the * DragPlane in SceneCanvas does the actual plane math, live-highlights the * nearest open mating connector, and magnetically snaps on release. */ export function ConnectorHandles() { const selectedIds = useBuilder((s) => s.selectedIds); const parts = useBuilder((s) => s.parts); const placingType = useBuilder((s) => s.placingType); const tool = useBuilder((s) => s.tool); if (placingType || tool === 'measure' || !selectedIds.length) return null; const conns: WorldConnector[] = []; for (const id of selectedIds) { const part = parts[id]; if (part) conns.push(...getWorldConnectors(part)); } const pipeIds = selectedIds.filter((id) => parts[id]?.type === 'pipe'); return ( <> {pipeIds.map((id) => ( ))} {conns.map((c) => ( ))} ); } const dirQuat = (dir: [number, number, number]) => new THREE.Quaternion().setFromUnitVectors( new THREE.Vector3(0, 0, 1), new THREE.Vector3(...dir).normalize(), ); function Handle({ conn, dimmed = false }: { conn: WorldConnector; dimmed?: boolean }) { const draggingId = useBuilder((s) => s.draggingId); const [hovered, setHovered] = useState(false); useCursor(hovered, 'grab'); const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]); const isDragSource = draggingId !== null && dragCtx.connectorKey === conn.key; const active = hovered || isDragSource; const onPointerDown = (e: ThreeEvent) => { if (e.button !== 0) return; const s = useBuilder.getState(); if (s.placingType || s.tool === 'measure') return; e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const handlePointerUp = (upEvt: PointerEvent) => { window.removeEventListener('pointerup', handlePointerUp); const dx = upEvt.clientX - startX; const dy = upEvt.clientY - startY; const dist = Math.hypot(dx, dy); if (dist < 4) { s.spawnPipeAtConnector(conn.key); } }; window.addEventListener('pointerup', handlePointerUp); beginPartDrag({ partId: conn.partId, grabPoint: conn.pos, mode: 'handle', connectorKey: conn.key, }); }; return ( {/* Generous invisible hit sphere (raycasts despite invisible material). */} { // No stopPropagation (it would starve the DragPlane of move events // mid-drag). Handles take hover priority over part bodies when they // are roughly as close as the nearest hit (mirrors the // PartMesh pointerdown guard). if (useBuilder.getState().draggingId) return; const firstHandle = e.intersections.find( (i) => i.object.userData?.connectorHandle, ); if ( firstHandle?.object !== e.object || firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5 ) return; setHovered(true); }} onPointerOut={() => setHovered(false)} > {/* Visible grab knob, always on top so it's never buried in geometry. */} {/* Direction ring hinting how the connector faces. */} ); } function PipeStretchHandles({ partId }: { partId: string }) { const part = useBuilder((s) => s.parts[partId]); const draggingId = useBuilder((s) => s.draggingId); if (!part || part.type !== 'pipe') return null; const conns = getWorldConnectors(part); const a = conns.find((c) => c.connectorId === 'a'); const b = conns.find((c) => c.connectorId === 'b'); if (!a || !b) return null; const len = part.params.length ?? 0; const mid: [number, number, number] = [ (a.pos[0] + b.pos[0]) / 2, (a.pos[1] + b.pos[1]) / 2 + 0.28, (a.pos[2] + b.pos[2]) / 2, ]; const editing = draggingId === partId && !!dragCtx.pipeStretch; return (
{len.toFixed(2)} ft {editing ? 'stretching' : 'drag ends to stretch'}
); } function PipeEndHandle({ conn }: { conn: WorldConnector }) { const draggingId = useBuilder((s) => s.draggingId); const [hovered, setHovered] = useState(false); useCursor(hovered, 'grab'); const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]); const active = hovered || (draggingId !== null && dragCtx.connectorKey === conn.key); const onPointerDown = (e: ThreeEvent) => { if (e.button !== 0) return; const s = useBuilder.getState(); if (s.placingType || s.tool === 'measure') return; e.stopPropagation(); beginPartDrag({ partId: conn.partId, grabPoint: conn.pos, mode: 'handle', connectorKey: conn.key, }); }; return ( { if (useBuilder.getState().draggingId) return; const firstHandle = e.intersections.find((i) => i.object.userData?.connectorHandle); if ( firstHandle?.object !== e.object || firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5 ) return; setHovered(true); }} onPointerOut={() => setHovered(false)} > ); } /** Pulsing glow ring on the nearest open connector during a handle drag. */ function SnapTargetHighlight() { const key = useBuilder((s) => s.snapTargetKey); const parts = useBuilder((s) => s.parts); const ref = useRef(null); useFrame(({ clock }) => { if (ref.current) { const k = 1 + 0.18 * Math.sin(clock.elapsedTime * 7); ref.current.scale.setScalar(k); } }); if (!key) return null; const partId = key.split(':')[0]; const part = parts[partId]; if (!part) return null; const conn = getWorldConnectors(part).find((c) => c.key === key); if (!conn) return null; return ( ); }