上节加了家具列表:

这节我们来做把家具拖拽到 3D 场景的功能。
如果直接在 3D 场景中添加家具我们会,就是直接改数据,加一个 furniture。
但问题是位置如何确定呢?
这种就是在拖拽过程中做计算了。
点击的时候,我们能拿到点击位置的 offset,然后计算出在场景中的哪个位置:

拖拽也是一样的。
拖动的时候,拿到这个 offset,然后计算出 3D 场景中的位置,在那个位置加一个对应的家具就好了。
这里我们要用到 react 的一个拖拽库 react-dnd
安装下:
pnpm install --save react-dnd react-dnd-html5-backend
首先在根组件加一个 Provider:

因为需要从一个组件拖到另一个组件,需要跨组件通信,所以要在最外层加一个 Provider。
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
<DndProvider backend={HTML5Backend}>
<App />
</DndProvider>
然后它的用法是在拖拽的目标上用 useDrag,拖拽到的目标用 useDrop。
我们先把菜单项抽离出单独的组件:

interface MenuItemProps {
imgSrc: string;
title: string;
}
function MenuItem(props: MenuItemProps) {
return <Card
hoverable
style={{ width: 200, margin: 20 }}
cover={<img
width={200}
src={props.imgSrc}
/>}
>
<Meta title={props.title} description="" />
</Card>
}

<MenuItem imgSrc="./bed.png" title="床"/>
<MenuItem imgSrc="./table.png" title="餐桌"/>
然后在这个组件里加 useDrag:

function MenuItem(props: MenuItemProps) {
const ref = useRef(null);
const [, drag]= useDrag({
type: '家具'
});
useEffect(() => {
drag(ref);
}, []);
return <Card
hoverable
style={{ width: 200, margin: 20 }}
cover={<img
width={200}
ref={ref}
src={props.imgSrc}
/>}
>
<Meta title={props.title} description="" />
</Card>
}
把拖动事件绑定在 img 元素上。

现在就可以拖动了。
然后 drop 在哪里呢?
在画布区。
我们加一下 useDrop:

const [, drop] = useDrop({
accept: '家具',
drop: (item, monitor) => {
console.log('drop');
},
})
useEffect(() => {
const div = document.getElementById('threejs-3d-container');
drop(div);
}, []);
这次我们不用 ref 了,直接根据 id 查找就行。
这里的 accept 和 useDrag 那里的 type 对应才能接收它的 drop。

试一下:

可以看到,现在确实能触发 drop 事件。
然后我们拿到 drop 的地方和元素左上角的距离。

const [, drop] = useDrop({
accept: '家具',
drop: (item, monitor) => {
const dom = document.getElementById('threejs-3d-container')!;
const clientOffset = monitor.getClientOffset();
const rect = dom.getBoundingClientRect();
if (clientOffset && rect) {
const x = clientOffset.x - rect.x;
const y = clientOffset.y - rect.y;
console.log(x, y)
}
},
})
这里就是用 drop 位置到可视区域顶部、左侧的距离,减去所在元素的距离顶部、左侧的距离:

剩下的就是 drop 位置到元素的顶部、左侧的距离。

有了这个位置之后呢?
其实就和点击的处理一模一样。
点击的时候拿到了 offset 就可以用 RasyCaster 来计算从这个位置的射线和哪些物体相交。

offset 有了,width、height 也有了,camera 之前也导出了。
那就可以计算添加家具的位置了:
我们要和地板来判断相交位置,把地板也用一个 group 来管理:


注意,这里改的是 3D 里的,别改成 2D 的了。
const floorGroup = new THREE.Group();
floorGroup.name = 'floors';
floorGroup.add(floor);
house.add(floorGroup);
这样查找就方便了:

const offsetX = clientOffset.x - rect.x;
const offsetY = clientOffset.y - rect.y;
const width = window.innerWidth;
const height = window.innerHeight - 60;
const y = -((offsetY / height) * 2 - 1);
const x = (offsetX / width) * 2 - 1;
const rayCaster = new THREE.Raycaster();
rayCaster.setFromCamera(new THREE.Vector2(x, y), camera3DRef.current!);
const scene3D = scene3DRef.current!;
const floorGroup = scene3D.getObjectByName('floors')!;
const intersections = rayCaster.intersectObjects(floorGroup.children);
if(intersections.length) {
const point = intersections[0].point;
console.log(point);
}
这里的逻辑和点击时的一样。
就是根据 offset 和 camera 的位置来发射一条射线。
用 RayCaster 的 api 判断和哪些对象相交。
打印下相交的点。

有了位置之后,接下来要做的事情就是在这个位置添加一条家具数据了。
加一个 addFurniture 方法:


addFurniture(furniture: Furniture): void;
addFurniture(furniture) {
set(state => {
return {
...state,
data: {
...state.data,
furnitures: [
...state.data.furnitures,
furniture
]
}
}
})
}
drop 的时候调用下:


if(intersections.length) {
const point = intersections[0].point;
addFurniture({
id: 'furniture' + Math.random().toString().slice(2, 8),
modelUrl: './dining-table.glb',
position: {
x: point.x,
y: 0,
z: point.z
},
rotation: {
x: 0,
y: 0,
z: 0
}
});
}
试下效果:

拖过去,然后刷新页面,可以看到确实加了一个家具,但是位置不对。
我们分别来解决下这俩问题。
位置不对的问题,是因为房子移动了一些距离。
去掉这行代码:

再试试:


清空数据,然后拖一个家具到那个位置,刷新页面,可以看到家具位置是正确的。
然后再来解决不渲染的问题:

这个是因为我们之前判断如果 house 存在,那就更新家具。
但 house 存在也可以能是新添加了家具。
我们加一个 else 就好了:
else {
const gltfLoader = new GLTFLoader();
const furnitures = houseObj.getObjectByName('furnitures')!;
gltfLoader.load(furniture.modelUrl, (gltf) => {
furnitures.add(gltf.scene);
gltf.scene.scale.setScalar(furniture.modelScale || 1);
gltf.scene.position.set(
furniture.position.x,
furniture.position.y,
furniture.position.z
);
gltf.scene.rotation.x = furniture.rotation.x;
gltf.scene.rotation.y = furniture.rotation.y;
gltf.scene.rotation.z = furniture.rotation.z;
gltf.scene.traverse(obj => {
(obj as any).target = gltf.scene;
});
gltf.scene.name = furniture.id
});
}
试下效果:

现在,拖拽家具到目标位置的功能就完成了。
案例代码上传了小册仓库
总结
这节我们实现了拖拽家具到房子任意位置的功能。
首先,我们用 react-dnd 这个拖拽库来实现拖拽,在要拖拽的目标上用 useDrag,拖拽到的目标用 useDrop。
我们主要是要拿到拖拽家具 img 到了 canvas 的哪个位置,拿到 offset
有了 offset 就可以用之前处理点击时的 RayCaster 来找到交点,然后在交点位置加一个家具。
现在还有一些小问题,下节我们继续完善。