集腋成裘,聚沙成塔。几秒钟虽然不长,却构成永恒长河中的伟大时代。——弗莱彻

Pascal Editor

有些编辑器喜欢写字,有些编辑器喜欢画画,而 Pascal Editor 更像一个戴着安全帽的建筑师:他不在纸上涂涂改改,他直接把你拽进三维空间里,抬手就是一堵墙,转身就是一块楼板,眨眼之间,一个 Site、一栋 Building、好几层 Level 就搭起来了——而且他跑在 React Three FiberWebGPU 的快车道上,动作利落,手感顺滑。

一句话就把他的人设钉死在门口:

A 3D building editor built with React Three Fiber and WebGPU.

视频入口也在 README 里(那是他给你递来的“看我施工”的短片):
https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b


住址(Homepage)

他把自己的工作室地址挂得很显眼:

https://editor.pascal.app


这是一个 Turborepo 的“城市规划”:三大街区,各司其职

Pascal Editor 不把自己塞进一个大杂烩仓库里,他更像一个讲究分区管理的城市规划师——把自己做成了一个 Turborepo monorepo,核心结构清清楚楚:

1
2
3
4
5
6
editor-v2/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schema definitions, state management, systems
│ └── viewer/ # 3D rendering components

它的世界里有三个主角:

  • @pascal-app/core:管“规则、数据与秩序”
  • @pascal-app/viewer:管“呈现、镜头与观感”
  • apps/editor:管“工具、交互与编辑体验”

Separation of Concerns:谁负责什么,它写得明明白白

Package Responsibility
@pascal-app/core Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus
@pascal-app/viewer 3D rendering via React Three Fiber, default camera/controls, post-processing
apps/editor UI components, tools, custom behaviors, editor-specific systems

它甚至把关系说得很拟人:

  • viewer:负责把场景“好好渲染出来”,给出 sensible defaults
  • editor:在 viewer 的基础上“长出双手”,提供交互工具、选择管理与编辑能力

三个 Store:三个大脑,彼此配合

Pascal Editor 把状态管理拆得很干净:每个包都有自己的 Zustand store,各管一块地盘。

Store Package Responsibility
useScene @pascal-app/core Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo.
useViewer @pascal-app/viewer Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode.
useEditor apps/editor Editor state: active tool, structure layer visibility, panel states, editor-specific preferences.

Access patterns:在 React 里订阅,在 React 外直接取

1
2
3
4
5
6
7
8
// Subscribe to state changes (React component)
const nodes = useScene((state) => state.nodes)
const levelId = useViewer((state) => state.selection.levelId)
const activeTool = useEditor((state) => state.tool)

// Access state outside React (callbacks, systems)
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })

这段代码就像他在说:
“想看我现在的状态?你可以坐在观众席(React)订阅,也可以走进后台(systems/callbacks)直接问我本人。”


Core Concepts:他是怎么把“建筑”变成“数据 + 系统 + 渲染”的

Nodes:3D 场景的原子单位

在 Pascal Editor 的世界里,一切都是 Node。所有 Node 都继承自 BaseNode

1
2
3
4
5
6
7
8
BaseNode {
id: string // Auto-generated with type prefix (e.g., "wall_abc123")
type: string // Discriminator for type-safe handling
parentId: string | null // Parent node reference
visible: boolean
camera?: Camera // Optional saved camera position
metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true })
}

他给 Node 的身份感很强:
每个 Node 都有 id、type、parentId,能被看见(visible),还能把相机位置“记在心里”(camera),甚至可以藏点小秘密(metadata)。

Node Hierarchy:一座建筑是怎样长出来的

1
2
3
4
5
6
7
8
9
10
Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)

但他又很务实:
Nodes 并不是一个嵌套树,而是一个 flat dictionaryRecord<id, Node>
父子关系靠 parentIdchildren 数组来维系。

这像极了一个“施工总控台”:表格里每个工位都能查到,关系清清楚楚,不用到处爬树找节点。


Scene State(Zustand Store):场景的记忆与行动力

@pascal-app/core 用 Zustand 管场景:

1
2
3
4
5
6
7
8
9
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates

createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}

并且它装了两层“记忆增强”中间件:

  • Persist:保存到 IndexedDB(排除 transient nodes)
  • Temporal(Zundo):undo/redo,50 步历史

也就是说:
他不仅会搭房子,还会记得你刚才怎么搭的;你后悔了,他能带你回到 50 步之前。


Scene Registry:不想在场景树里迷路?我直接给你地图

Registry 把 node id 映射到 Three.js 的 Object3D:

1
2
3
4
5
6
7
8
9
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}

渲染器用 useRegistry 把自己的 ref 登记进去:

1
2
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)

这就像他在说:
“别在场景图里慢慢找,我把每个物体的‘身份证’和‘位置’都登记了,系统要用,直接拿。”


Node Renderers:每一种 Node,都有自己的“造型师”

渲染结构大概是这样:

1
2
3
4
5
6
7
8
9
SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...

Pattern 非常明确:

  1. Renderer 先造一个 placeholder mesh/group
  2. useRegistry 注册
  3. 系统(Systems)根据 node 数据更新几何

简化版示例(WallRenderer):

1
2
3
4
5
6
7
8
9
10
11
12
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)

return (
<mesh ref={ref}>
<boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */}
<meshStandardMaterial />
{node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
</mesh>
)
}

这个设计很像“先搭脚手架,再浇筑混凝土”:
渲染器先把结构摆好,真正的几何细节交给系统在渲染循环里生成与替换。


Systems:它们是“施工队”,在每一帧里把脏活累活干完

Systems 是在 render loop(useFrame)里跑的 React 组件,会处理 store 里标记为 dirty 的节点。

Core Systems(@pascal-app/core)

System Responsibility
WallSystem Generates wall geometry with mitering and CSG cutouts for doors/windows
SlabSystem Generates floor geometry from polygons
CeilingSystem Generates ceiling geometry
RoofSystem Generates roof geometry
ItemSystem Positions items on walls, ceilings, or floors (slab elevation)

Viewer Systems(@pascal-app/viewer)

System Responsibility
LevelSystem Handles level visibility and vertical positioning (stacked/exploded/solo modes)
ScanSystem Controls 3D scan visibility
GuideSystem Controls guide image visibility

Processing Pattern:只处理 dirty nodes

1
2
3
4
5
6
7
8
9
10
11
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = useScene.getState().nodes[id]

// Update geometry, transforms, etc.
updateGeometry(obj, node)

dirtyNodes.delete(id)
}
})

它像一个超高效的队长:
“谁变了我就修谁,没变的别打扰我——每一帧都只干必须干的事。”


Dirty Nodes:哪里变了,我就把哪里点亮

当 Node 变化时,它会被标记 dirty,下一帧系统会重新算它的几何,然后清除标记。

1
2
3
4
5
// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes

你也能手动标记:

1
useScene.getState().dirtyNodes.add(wallId)

Event Bus:组件之间不喊话,用“广播站”

Inter-component communication 用 typed event emitter(mitt):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })

// Grid events (background)
emitter.on('grid:click', (event) => { ... })

// Event payload
NodeEvent {
node: AnyNode
position: [x, y, z]
localPosition: [x, y, z]
normal?: [x, y, z]
stopPropagation: () => void
}

它就像一个很有礼貌的城市:
你点墙,墙发广播;你滑过物件,物件发广播;需要阻止冒泡,就 stopPropagation()


Spatial Grid Manager:放不放得下,撞不撞得上,它来判

它负责 collision detection 与 placement validation:

1
2
3
spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)

这个角色像一个严谨的监理:
“家具能不能摆这?尺寸会不会穿模?墙上挂得稳不稳?地面高度在哪?别猜,我来算。”


Editor Architecture:Editor 给 Viewer 装上“工具手套”

Editor 在 viewer 之上,主要带来三件大事:

Tools:工具栏里的“施工模式切换器”

  • SelectTool - Selection and manipulation
  • WallTool - Draw walls
  • ZoneTool - Create zones
  • ItemTool - Place furniture/fixtures
  • SlabTool - Create floor slabs

它像一个工具箱,里面每一把工具都有自己的脾气:
选中、画墙、划分区域、摆放物件、铺楼板……你点谁,谁就接管你的鼠标与输入。


Selection Manager:选择也要讲层级礼仪

选择管理是分层的:

1
Site → Building → Level → Zone → Items

每一层都有自己的 hover/click 策略——像是在说:
“你要选东西,先看你站在哪一层;在不同的深度,选择���语义也不同。”


Editor-Specific Systems:它也有自己的后台队伍

  • ZoneSystem:根据 level mode 控制 zone visibility
  • 自定义相机控制(node focusing)

Data Flow:从你手指一动到几何更新,整套流水线像滚轮一样转起来

README 直接画了流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
User Action (click, drag)

Tool Handler

useScene.createNode() / updateNode()

Node added/updated in store
Node marked dirty

React re-renders NodeRenderer
useRegistry() registers 3D object

System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag

这是一条极其“工业化”的链路:
你操作 → 工具处理 → 数据变更 → 标记 dirty → 渲染器更新骨架 → 系统更新几何 → 清理 dirty。


Technology Stack:它的骨骼与肌肉都写在墙上

仓库里给出了两套(主 README 与 apps/editor README)略有差异的 stack 描述,但核心一致:它是一套围绕 React / Next / Three / WebGPU / Zustand 的“3D 编辑器工程体系”。

主 README 写的是:

  • React 19 + Next.js 16
  • Three.js (WebGPU renderer)
  • React Three Fiber + Drei
  • Zustand
  • Zod
  • Zundo
  • three-bvh-csg
  • Turborepo
  • Bun

apps/editor README 里还写到:

  • React 19 + Next.js 15
  • three-bvh-csg
  • (Getting Started 例子用 pnpm)

Getting Started:把他叫到本地开工

开发(推荐从根目录启动,热更新能照顾到所有包)

主 README 的开发方式(Bun):

1
2
3
4
5
6
7
8
9
10
11
# Install dependencies
bun install

# Run development server (builds packages + starts editor with watch mode)
bun dev

# This will:
# 1. Build @pascal-app/core and @pascal-app/viewer
# 2. Start watching both packages for changes
# 3. Start the Next.js editor dev server
# Open http://localhost:3000

它还反复强调一个习惯(像个认真的工头在喊):

Always run bun dev from the root directory … hot reload … packages/core/src/ or packages/viewer/src/.

apps/editor README 也给了 pnpm 的启动方式:

1
2
3
4
5
6
7
# Install dependencies
pnpm install

# Run development server
pnpm dev

# Open http://localhost:3000

生产构建(Turborepo 开始发力)

1
2
3
4
5
# Build all packages
turbo build

# Build specific package
turbo build --filter=@pascal-app/core

发布 packages(把 core/viewer 推到 npm)

1
2
3
4
5
6
# Build packages
turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer

# Publish to npm
npm publish --workspace=@pascal-app/core --access public
npm publish --workspace=@pascal-app/viewer --access public

Key Files:想快速熟悉代码,从这些入口钻进去

Path Description
packages/core/src/schema/ Node type definitions (Zod schemas)
packages/core/src/store/use-scene.ts Scene state store
packages/core/src/hooks/scene-registry/ 3D object registry
packages/core/src/systems/ Geometry generation systems
packages/viewer/src/components/renderers/ Node renderers
packages/viewer/src/components/viewer/ Main Viewer component
apps/editor/components/tools/ Editor tools
apps/editor/store/ Editor-specific state

这些路径就像这位“3D 建筑师”递给你的工地图纸:
你想看规则去 schema,你想看数据去 store,你想看渲染去 renderers,你想看工具去 tools。


@pascal-app/core:这位“地基工程师”也可以单独雇佣

仓库里 packages/core/README.md 把 core 单独介绍为:

Core library for Pascal 3D building editor.

Installation

1
npm install @pascal-app/core

Peer Dependencies

1
npm install react three @react-three/fiber @react-three/drei

What’s Included(core 的“职责清单”)

  • Node Schemas(Zod)
  • Scene State(Zustand + IndexedDB persist + undo/redo)
  • Systems(walls/floors/ceilings/roofs)
  • Scene Registry(id → Object3D)
  • Spatial Grid(碰撞与放置校验)
  • Event Bus(typed emitter)
  • Asset Storage(IndexedDB file storage)

Usage:用 core 直接造一堵墙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useScene, WallNode, ItemNode } from '@pascal-app/core'

// Create a wall
const wall = WallNode.parse({
points: [[0, 0], [5, 0]],
height: 3,
thickness: 0.2,
})

useScene.getState().createNode(wall, parentLevelId)

// Subscribe to scene changes
function MyComponent() {
const nodes = useScene((state) => state.nodes)
const walls = Object.values(nodes).filter(n => n.type === 'wall')

return <div>Total walls: {walls.length}</div>
}

Node Types:core 里都有哪些“建筑积木”

  • SiteNode
  • BuildingNode
  • LevelNode
  • WallNode
  • SlabNode
  • CeilingNode
  • RoofNode
  • ZoneNode
  • ItemNode
  • ScanNode
  • GuideNode

Systems:core 里的“几何施工队”

  • WallSystem
  • SlabSystem
  • CeilingSystem
  • RoofSystem
  • ItemSystem

License

MIT