100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import { useLayoutEffect, useMemo, useRef } from 'react';
|
|
import * as THREE from 'three';
|
|
import { PivotControls } from '@react-three/drei';
|
|
import type { Vec3 } from '../types';
|
|
import { snapValue, useBuilder } from '../store/builderStore';
|
|
|
|
/**
|
|
* SelectionGizmo — a translate-only pivot gizmo at the selection centroid.
|
|
* Dragging an axis arrow / plane slider moves every selected part rigidly in
|
|
* all three dimensions (grid-snapped, floor-clamped), as one undo step.
|
|
*/
|
|
export function SelectionGizmo() {
|
|
const selectedIds = useBuilder((s) => s.selectedIds);
|
|
const parts = useBuilder((s) => s.parts);
|
|
const draggingId = useBuilder((s) => s.draggingId);
|
|
const placingType = useBuilder((s) => s.placingType);
|
|
const tool = useBuilder((s) => s.tool);
|
|
|
|
const matrix = useMemo(() => new THREE.Matrix4(), []);
|
|
const draggingRef = useRef(false);
|
|
const startRef = useRef<{ centroid: Vec3; positions: Record<string, Vec3> } | null>(null);
|
|
|
|
const selected = selectedIds.map((id) => parts[id]).filter(Boolean);
|
|
const centroid: Vec3 = [0, 0, 0];
|
|
for (const p of selected) {
|
|
centroid[0] += p.position[0] / selected.length;
|
|
centroid[1] += p.position[1] / selected.length;
|
|
centroid[2] += p.position[2] / selected.length;
|
|
}
|
|
|
|
// Keep the gizmo parked at the selection centroid whenever it isn't the
|
|
// thing doing the moving (selection changes, undo, inspector edits, ...).
|
|
useLayoutEffect(() => {
|
|
if (!draggingRef.current) matrix.setPosition(centroid[0], centroid[1], centroid[2]);
|
|
});
|
|
|
|
if (!selected.length || draggingId || placingType || tool === 'measure') return null;
|
|
|
|
const onDragStart = () => {
|
|
draggingRef.current = true;
|
|
const s = useBuilder.getState();
|
|
s.pushHistory();
|
|
s.setGizmoDragging(true);
|
|
const positions: Record<string, Vec3> = {};
|
|
for (const id of s.selectedIds) {
|
|
const p = s.parts[id];
|
|
if (p) positions[id] = [...p.position] as Vec3;
|
|
}
|
|
startRef.current = { centroid: [...centroid] as Vec3, positions };
|
|
};
|
|
|
|
const onDrag = (l: THREE.Matrix4) => {
|
|
const start = startRef.current;
|
|
if (!start) return;
|
|
const s = useBuilder.getState();
|
|
matrix.copy(l); // gizmo follows the pointer
|
|
const v = new THREE.Vector3().setFromMatrixPosition(l);
|
|
const snap = (n: number) => (s.snapToGrid ? snapValue(n, s.gridSize) : n);
|
|
const delta: Vec3 = [
|
|
snap(v.x - start.centroid[0]),
|
|
snap(v.y - start.centroid[1]),
|
|
snap(v.z - start.centroid[2]),
|
|
];
|
|
// Don't let any part of the group sink below the floor.
|
|
let minY = Infinity;
|
|
for (const p of Object.values(start.positions)) minY = Math.min(minY, p[1]);
|
|
if (Number.isFinite(minY)) delta[1] = Math.max(delta[1], -minY);
|
|
|
|
const updates: Record<string, Vec3> = {};
|
|
for (const [id, sp] of Object.entries(start.positions)) {
|
|
updates[id] = [sp[0] + delta[0], sp[1] + delta[1], sp[2] + delta[2]];
|
|
}
|
|
s.setPositions(updates);
|
|
};
|
|
|
|
const onDragEnd = () => {
|
|
draggingRef.current = false;
|
|
startRef.current = null;
|
|
useBuilder.getState().setGizmoDragging(false);
|
|
};
|
|
|
|
return (
|
|
<PivotControls
|
|
matrix={matrix}
|
|
autoTransform={false}
|
|
fixed
|
|
scale={72}
|
|
lineWidth={3}
|
|
depthTest={false}
|
|
disableRotations
|
|
disableScaling
|
|
axisColors={['#f87171', '#4ade80', '#60a5fa']}
|
|
hoveredColor="#fbbf24"
|
|
onDragStart={onDragStart}
|
|
onDrag={onDrag}
|
|
onDragEnd={onDragEnd}
|
|
/>
|
|
);
|
|
}
|