Files
hydro-builder-app/src/components/ConnectorHandles.tsx
2026-06-13 17:36:44 -07:00

286 lines
9.6 KiB
TypeScript

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) => (
<PipeStretchHandles key={`pipe-stretch-${id}`} partId={id} />
))}
{conns.map((c) => (
<Handle key={c.key} conn={c} dimmed={parts[c.partId]?.type === 'pipe'} />
))}
<SnapTargetHighlight />
</>
);
}
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<PointerEvent>) => {
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 (
<group position={conn.pos}>
{/* Generous invisible hit sphere (raycasts despite invisible material). */}
<mesh
userData={{ connectorHandle: true }}
onPointerDown={onPointerDown}
onPointerOver={(e) => {
// 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)}
>
<sphereGeometry args={[0.22, 10, 10]} />
<meshBasicMaterial visible={false} />
</mesh>
{/* Visible grab knob, always on top so it's never buried in geometry. */}
<mesh scale={active ? 1.4 : 1} renderOrder={998}>
<sphereGeometry args={[dimmed ? 0.05 : 0.075, 14, 14]} />
<meshBasicMaterial
color={active ? '#bae6fd' : '#38bdf8'}
toneMapped={false}
depthTest={false}
transparent
opacity={dimmed ? (active ? 0.85 : 0.45) : 0.95}
/>
</mesh>
{/* Direction ring hinting how the connector faces. */}
<mesh quaternion={quat} scale={active ? 1.25 : 1} renderOrder={998}>
<torusGeometry args={[dimmed ? 0.11 : 0.15, dimmed ? 0.014 : 0.018, 8, 24]} />
<meshBasicMaterial
color="#38bdf8"
toneMapped={false}
depthTest={false}
transparent
opacity={dimmed ? (active ? 0.8 : 0.35) : active ? 0.95 : 0.55}
/>
</mesh>
</group>
);
}
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 (
<group>
<Line
points={[a.pos, b.pos]}
color={editing ? '#7dd3fc' : '#38bdf8'}
lineWidth={2}
transparent
opacity={editing ? 0.95 : 0.55}
/>
<PipeEndHandle conn={a} />
<PipeEndHandle conn={b} />
<Html position={mid} center distanceFactor={12}>
<div
className={`rounded-full border px-2 py-1 text-[10px] font-semibold whitespace-nowrap shadow-lg backdrop-blur ${
editing
? 'border-sky-400 bg-sky-300/95 text-sky-950'
: 'border-zinc-700 bg-zinc-950/90 text-zinc-200'
}`}
>
{len.toFixed(2)} ft
<span className="ml-1 text-zinc-400">{editing ? 'stretching' : 'drag ends to stretch'}</span>
</div>
</Html>
</group>
);
}
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<PointerEvent>) => {
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 (
<group position={conn.pos}>
<mesh
userData={{ connectorHandle: true }}
onPointerDown={onPointerDown}
onPointerOver={(e) => {
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)}
>
<sphereGeometry args={[0.28, 12, 12]} />
<meshBasicMaterial visible={false} />
</mesh>
<mesh quaternion={quat} scale={active ? 1.12 : 1} renderOrder={998}>
<cylinderGeometry args={[0.05, 0.1, 0.18, 12]} />
<meshBasicMaterial
color={active ? '#e0f2fe' : '#7dd3fc'}
toneMapped={false}
depthTest={false}
transparent
opacity={0.98}
/>
</mesh>
<mesh quaternion={quat} position={[0, 0, 0.11]} scale={active ? 1.18 : 1} renderOrder={998}>
<coneGeometry args={[0.08, 0.16, 12]} />
<meshBasicMaterial
color={active ? '#38bdf8' : '#0ea5e9'}
toneMapped={false}
depthTest={false}
transparent
opacity={0.95}
/>
</mesh>
</group>
);
}
/** 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<THREE.Group>(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 (
<group position={conn.pos}>
<group ref={ref}>
<mesh quaternion={dirQuat(conn.dir)} renderOrder={999}>
<torusGeometry args={[0.24, 0.035, 10, 28]} />
<meshBasicMaterial color="#34d399" toneMapped={false} depthTest={false} transparent opacity={0.95} />
</mesh>
<mesh renderOrder={999}>
<sphereGeometry args={[0.1, 12, 12]} />
<meshBasicMaterial color="#6ee7b7" toneMapped={false} depthTest={false} transparent opacity={0.9} />
</mesh>
</group>
</group>
);
}