Skip to content

91. 实战:Three.js Editor(八)

Published:

左边的编辑器告一段落,这节我们来做右边的部分:

image.png

右边上面是场景的对象树的展示,下面是属性的编辑。

我们先写上面部分:

image.png

如何拿到这样的场景中所有的对象呢?

用 scene.traverse 就可以

image.png

但 threejs 的场景是在 Main 组件里,我们要把它传到 Properties 组件里,明显需要通过 store。

我们在 store 里加个属性方法:

image.png

scene: null,
setScene(obj) {
    set({
        scene: obj
    })
},

然后在 Main 组件里用 setScene 把 scene 放入 store

image.png

接着在 Properties 组件里取出来:

image.png

const { selectedObj, data, scene } = useThreeStore();

useEffect(() => {
    if(scene?.traverse) {
        scene.traverse(obj => {
            console.log(obj);
        })
    }
}, [scene]);

拿到 store 里的 scene,遍历打印所有的对象

image.png

可以看到,它会遍历打印所有的对象,比如 GridHelper 下的每一条 Line。

其实没必要用 traverse,直接用 scene.children 取就行,因为我们只有一层。

useEffect(() => {
    if(scene?.children) {
        scene.children.forEach(item => {
            console.log(item);
        });
    }
}, [scene]);

第一层只有这 4 个对象:

image.png

我们在渲染完物体之后也重新更新一下 scene:

image.png

react 会浅层对比 scene 有没有变化,这里 clone 一下来触发更新。

当我添加一个立方体、一个圆柱之后,拿到的对象都是对的:

image.png

接下来把它用树形组件展示出来就行。

image.png

image.png

引入 Tree 组件,它的 treeData 是数据,expandedKeys 指定哪些 key 展开。

创建一个 state,在 scene 更新的时候,设置 state 数据。

这里要注意的是,如果是 Mesh,title 就展示几何体的类型,比如 BoxGeometry,否则展示对象的类型

import { useEffect, useState } from "react";
import { useThreeStore } from "../../store";
import { Tree } from "antd";

function Properties() {
    const { selectedObj, data, scene } = useThreeStore();

    const [treeData, setTreeData] = useState();
    useEffect(() => {
        if(scene?.children) {
            const tree = scene.children.map(item => {
                return {
                    title: item.isMesh ?  item.geometry.type : item.type,
                    key: item.type + item.name + item.id
                }
            });

            setTreeData([
                {
                    title: 'Scene',
                    key: 'root',
                    children: tree
                }
            ]);
        }
    }, [scene]);

    return <div className="Properties">
        <Tree treeData={treeData} expandedKeys={['root']}/>
        {/* <div>selectedObj: {selectedObj?.name}</div>
        <pre>
        {JSON.stringify(data, null, 2)}
        </pre> */}
    </div>
}

export default Properties;

试下效果:

image.png

可以看到,对象树展示出来的,只是多了一个 Object3D,这是 TransformControls 的 helper 加的

我们过滤下:

image.png

const tree = scene.children.map(item => {

    if(item.isTransformControlsRoot) {
        return null;
    }

    return {
        title: item.isMesh ?  item.geometry.type : item.type,
        key: item.type + item.name + item.id
    }
}).filter(item => item !== null);

这样就好了:

image.png

添加一个圆柱:

2025-05-17 18.16.31.gif

右边也同步变化了。

然后点击右边的 item 的时候,也要选中左边对应的物体。

这个就是在全局 store 存一个 selectedObjName,变化的时候,从 scene 种找到对应的物体来 attach 就好了。

image.png

selectedObjName: null,
setSelectedObjName(name) {
    set({
        selectedObjName: name
    })
},

store 里添加这个属性和方法。

然后在 Properties 组件里点击 tree 的 item 的时候,设置 selectedObjName:

image.png

image.png

import { useEffect, useState } from "react";
import { useThreeStore } from "../../store";
import { Tree } from "antd";

function Properties() {
    const { setSelectedObjName, selectedObj, data, scene } = useThreeStore();

    const [treeData, setTreeData] = useState();
    useEffect(() => {
        if(scene?.children) {
            const tree = scene.children.map(item => {
                if(item.isTransformControlsRoot) {
                    return null;
                }

                return {
                    title: item.isMesh ?  item.geometry.type : item.type,
                    key: item.type + item.name + item.id,
                    name: item.name
                }
            }).filter(item => item !== null);

            setTreeData([
                {
                    title: 'Scene',
                    key: 'root',
                    children: tree
                }
            ]);
        }
    }, [scene]);

    function handleSelect(selectKeys, info) {
        const name = info.node.name;
        
        setSelectedObjName(name);
    }

    return <div className="Properties">
        <Tree treeData={treeData} expandedKeys={['root']} onSelect={handleSelect}/>
        {/* <div>selectedObj: {selectedObj?.name}</div>
        <pre>
        {JSON.stringify(data, null, 2)}
        </pre> */}
    </div>
}

export default Properties;

然后在 Main 组件里,在 selectedObjName 变化的时候,遍历找对应的 obj,然后让 TransformControls attach 它。

我们先暴露下 attach 方法:

image.png

function transformControlsAttachObj(obj) {
    transformControls.attach(obj);
}

和之前切换 mode 一样,这里也是导出一个方法,在组件利用 ref 保存。

当然,这里直接把 TransformControls 导出会更方便。

image.png

const transformControlsAttachObjRef = useRef();
transformControlsAttachObjRef.current = transformControlsAttachObj;

然后在 name 切换的时候,遍历找到 obj,让 TransformControls attach 它:

image.png

useEffect(() => {
    if(selectedObjName) {
        const obj = sceneRef.current.getObjectByName(selectedObjName);
        setSelectedObj(obj);
        transformControlsAttachObjRef.current(obj);
    }
}, [selectedObjName]);

试下效果:

2025-05-17 18.40.33.gif

这样,点击 tree,选中对应的对象就完成了。

最后,我们来修一个 bug:

2025-05-17 19.00.24.gif

选中物体点击 delete 的时候,物体删除了,对应的 helper 还在,并且控制台报错了。

说是 helper attach 到的物体不在场景树中。

这个问题的解决也比较简单,就是先让 helper detach,之后再删就好了。

改一下暴露的 attach 方法,如果 obj 是空,就 detach

image.png

if(!obj) {
    transformControls.detach();
    return;
}

然后在删除物体之前,先 detach 一下:

image.png

if(selectedObj) {
    transformControlsAttachObjRef.current(null);
    sceneRef.current.remove(selectedObj);
    removeMesh(selectedObj.name);
}

试下效果:

2025-05-17 19.13.12.gif

这样就好了。

案例代码上传了小册仓库

总结

这节我们实现了场景树的展示,点击树选中对应的对象。

首先我们把 scene 保存到了 store,在 Properties 组件里取出来遍历。

因为只有一层,我们直接用 scene.chilren 拿到数据,用 antd 的 Tree 组件展示。

之后添加了一个 selectedObjName 的 store 属性,点击树的 item 的时候,修改它。

然后在 Main 组件里查找对应的 obj,让 TransformControls attach 上它就好了。

这样,场景树就完成了。

评论