📌 项目地址pascalorg/editor | ⭐ 16,310 颗星 | 🔧 TypeScript | 📜 MIT

这篇解读,我们不聊功能清单。我们聊聊这个拥有 16310 个 star 的 TypeScript 项目背后,几个关键的架构决策。这些决策让一个复杂的 3D 编辑器变得可维护,对构建任何有复杂状态和交互的大型应用都有启发。

为什么是单体仓库(Monorepo)?

Pascal Editor 没有把所有东西塞进一个巨大的项目。它用 Turborepo 组织了仓库,结构清晰:

editor-v2/
├── apps/
│   └── editor/          # Next.js 应用
├── packages/
│   ├── core/            # 核心逻辑
│   └── viewer/          # 3D 渲染

这解决了核心问题:3D 场景的核心数据与逻辑,应该和特定的 UI 和交互工具分离。

具体来说,三个部分各司其职:

  • @pascal-app/core 包是心脏。它定义节点数据结构(Schema),用 Zustand 管理场景状态,负责几何生成这类“系统”操作,还提供空间查询和事件总线。它是纯逻辑,不关心画面怎么画。
  • @pascal-app/viewer 包是眼睛。它基于 React Three Fiber 实现 3D 渲染,提供合理的默认相机、控制器和后期处理。它读取 core 包的状态,把场景“看”出来。
  • apps/editor 是手和界面。它是 Next.js 应用,在 viewer 渲染的基础上,叠加了交互工具(比如画墙、选择物体)、编辑器 UI 面板和具体的行为逻辑。

这种分离的好处是,如果只想展示一个预设的 3D 场景,只引入 @pascal-app/core@pascal-app/viewer 就够了,不需要整个编辑器。

状态管理:不是一座大山,而是三座小丘

一个编辑器最大的状态管理挑战是:不同部分关心的状态维度完全不同。硬塞在一个 store 里会变得一团糟。Pascal Editor 为三个包分别建立了独立的 Zustand Store,思路很清晰:

存储 位置 管什么
useScene @pascal-app/core 场景的“事实”:所有节点(墙、楼板、楼层…)的原始数据、它们的增删改查。状态通过 IndexedDB 持久化,并且用 Zundo 库实现了撤销/重做。
useViewer @pascal-app/viewer 场景的“观看”状态:当前选中的是哪栋楼、哪一层?楼层是堆叠显示、爆炸显示还是单独显示?相机是什么模式?
useEditor apps/editor 编辑器的“工具”状态:当前激活的工具(选择、绘制、移动…)、结构面板是否打开、具体的编辑偏好。

代码里的访问方式也体现了这种分层:

// React 组件里订阅特定片段
const nodes = useScene((state) => state.nodes)
const activeTool = useEditor((state) => state.tool)

// 在非 React 代码(如一个独立的处理函数)里直接读取或操作
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })

这避免了全局状态的混杂。viewereditor 的 Store 都可以从 useScene 读取数据,但 core 的 Store 完全不知道 UI 的存在。这是单向数据流,易于理解和调试。

数据模型的“扁平化”设计

对于 3D 场景,最直观的数据结构可能是嵌套的树(Site 包含 Building,Building 包含 Level…)。但 Pascal Editor 选择了另一种方式:用扁平的字典(Record)存储所有节点

每个节点的基础结构如下:

BaseNode {
  id: string              // 如 "wall_abc123",带类型前缀
  type: string            // "wall", "level", "building" 等
  parentId: string | null // 指向父节点的 ID
  visible: boolean
  camera?: Camera         // 可选,保存特定视角
  metadata?: JSON         // 可选,任意数据
}

节点间的父子关系(比如墙属于哪个楼层)通过 parentId 引用,形成逻辑上的树:

Site
└── Building
    └── Level
        ├── Wall -> Item (门、窗)
        ├── Slab (楼板)
        ├── Ceiling -> Item (灯具)
        ...

这种扁平存储+ID 引用的模型,在编辑器场景下优势明显:

  1. 查找和更新单个节点极快:直接用 ID 在字典里取,O(1) 复杂度。如果要改一扇门的属性,不需要从 Site 一路向下查找。
  2. 跨节点关联查询高效:比如“找出所有属于 Level_1 的 Wall”,可以通过遍历所有 parentIdLevel_1typewall 的节点实现,这在空间索引等系统逻辑中很常见。
  3. 与状态管理库配合更好:Zustand 等库处理这种扁平、更新明确的数据结构非常高效,便于实现精确的响应式更新和撤销/重做。

写在最后

Pascal Editor 的价值,不仅在于它是一个能用的 3D 建筑编辑器。它作为一个开源项目,其源码展示了一套清晰、模块化的复杂应用构建方法。从仓库结构划分责任,到状态管理的精细分层,再到为性能优化的数据模型设计,每一步都有明确的工程考量。

如果你正在用 React Three Fiber 或类似的前端框架构建 3D 工具、数据可视化或复杂交互应用,这个项目的代码结构值得你花时间读一读。它提供的不是具体功能的代码片段,而是一个经过验证的、可靠的骨架。

这篇文章对你有帮助吗?

发表回复