286 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|