Skip to content

120. 实战:酷家乐装修编辑器(二十四)

Published:

上节加了家具列表:

image.png

这节我们来做把家具拖拽到 3D 场景的功能。

如果直接在 3D 场景中添加家具我们会,就是直接改数据,加一个 furniture。

但问题是位置如何确定呢?

这种就是在拖拽过程中做计算了。

点击的时候,我们能拿到点击位置的 offset,然后计算出在场景中的哪个位置:

image.png

拖拽也是一样的。

拖动的时候,拿到这个 offset,然后计算出 3D 场景中的位置,在那个位置加一个对应的家具就好了。

这里我们要用到 react 的一个拖拽库 react-dnd

安装下:

pnpm install --save react-dnd react-dnd-html5-backend

首先在根组件加一个 Provider:

image.png

因为需要从一个组件拖到另一个组件,需要跨组件通信,所以要在最外层加一个 Provider。

import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
<DndProvider backend={HTML5Backend}>
    <App />
</DndProvider>

然后它的用法是在拖拽的目标上用 useDrag,拖拽到的目标用 useDrop。

我们先把菜单项抽离出单独的组件:

image.png

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>
}

image.png

<MenuItem imgSrc="./bed.png" title="床"/>
<MenuItem imgSrc="./table.png" title="餐桌"/>

然后在这个组件里加 useDrag:

image.png

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 元素上。

2025-07-20 14.11.37.gif

现在就可以拖动了。

然后 drop 在哪里呢?

在画布区。

我们加一下 useDrop:

image.png

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。

image.png

试一下:

2025-07-20 14.14.47.gif

可以看到,现在确实能触发 drop 事件。

然后我们拿到 drop 的地方和元素左上角的距离。

image.png

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 位置到可视区域顶部、左侧的距离,减去所在元素的距离顶部、左侧的距离:

image.png

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

2025-07-20 19.55.12.gif

有了这个位置之后呢?

其实就和点击的处理一模一样。

点击的时候拿到了 offset 就可以用 RasyCaster 来计算从这个位置的射线和哪些物体相交。

image.png

offset 有了,width、height 也有了,camera 之前也导出了。

那就可以计算添加家具的位置了:

我们要和地板来判断相交位置,把地板也用一个 group 来管理:

image.png

image.png

注意,这里改的是 3D 里的,别改成 2D 的了。

const floorGroup = new THREE.Group();
floorGroup.name = 'floors';
floorGroup.add(floor);
house.add(floorGroup);

这样查找就方便了:

image.png

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 判断和哪些对象相交。

打印下相交的点。

2025-07-20 20.19.33.gif

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

加一个 addFurniture 方法:

image.png

image.png

addFurniture(furniture: Furniture): void;
addFurniture(furniture) {
    set(state => {
        return {
            ...state,
            data: {
                ...state.data,
                furnitures: [
                    ...state.data.furnitures,
                    furniture
                ]
            }
        }
    })
}

drop 的时候调用下:

image.png

image.png

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
        }
    });
}

试下效果:

2025-07-25 19.34.21.gif

拖过去,然后刷新页面,可以看到确实加了一个家具,但是位置不对。

我们分别来解决下这俩问题。

位置不对的问题,是因为房子移动了一些距离。

去掉这行代码:

image.png

再试试:

image.png

2025-07-25 19.38.06.gif

清空数据,然后拖一个家具到那个位置,刷新页面,可以看到家具位置是正确的。

然后再来解决不渲染的问题:

image.png

这个是因为我们之前判断如果 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
    });
}

试下效果:

2025-07-25 19.42.20.gif

现在,拖拽家具到目标位置的功能就完成了。

案例代码上传了小册仓库

总结

这节我们实现了拖拽家具到房子任意位置的功能。

首先,我们用 react-dnd 这个拖拽库来实现拖拽,在要拖拽的目标上用 useDrag,拖拽到的目标用 useDrop。

我们主要是要拿到拖拽家具 img 到了 canvas 的哪个位置,拿到 offset

有了 offset 就可以用之前处理点击时的 RayCaster 来找到交点,然后在交点位置加一个家具。

现在还有一些小问题,下节我们继续完善。

评论