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