Initial project import
This commit is contained in:
285
src/components/ConnectorHandles.tsx
Normal file
285
src/components/ConnectorHandles.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user