三维建筑编辑器 Pascal Editor 的平面图模式与图纸导出链路
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
平面图模式不是给三维场景换一套配色,它是在补一批三维数据里根本不存在的信息。 三维模型里有墙的两个端点、有厚度、有高度,但没有「这道墙该标多少毫米、标在哪一侧、字往哪边转、和旁边那个标签撞了该谁让」。这些东西在三维场景里一条都用不上,一旦要打印成纸,它们就是图纸的全部。Pascal Editor 这个跑在浏览器里的开源三维建筑编辑器(名字里的 Pascal 指的是这个编辑器项目,与 Pascal 编程语言、与压强单位都没有关系),把这段补信息的逻辑摊在了几个可读的文件里,读一遍比看十篇讲「BIM 转图纸」的文章有用。
先说清楚两个词,后面全篇会反复用。平面图是在某个高度把建筑水平切一刀、然后从上往下看得到的投影图,所以墙被切成两条线、门窗变成豁口,家具则是俯视轮廓。**楼层(level)**是编辑器里承载某一层所有构件的节点,一层一张图。
站内已有几篇相邻话题:结构化输出为什么不稳 讲的是模型吐 JSON 这一层的可靠性,Agent 工具返回值怎么设计 讲工具回给模型的内容如何组织,AI 翻译工具的取舍 讲的是另一种表示之间的转换该保什么、丢什么。本篇不重复这三件事,只盯一个具体的开源实现:同一份三维节点数据被当成二维图纸打印出来时,代码到底在哪几个位置做了额外判断。
一、这块解决的问题:三维里没有的东西,图纸上必须有
在这个仓库里,每一种构件都是一个节点类型,定义集中在 packages/nodes/src/ 下。这个目录当前有 46 个子目录,其中 shared/ 装的是公共代码不算节点类型,也就是 45 种节点类型:wall、slab、door、window、stair、column、roof、zone、structural-grid 这些结构件,加上 duct-segment、pipe-segment、solar-panel 这类设备件。顺手说一句 slab 就是楼板,一层地面那块平的板。
关键在于,这些节点各自带了一个 floorplan.ts——packages/nodes/src/wall/ 和 packages/nodes/src/slab/ 下都能看到同名文件。节点定义里对应的字段签名在 packages/core/src/registry/types.ts:
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
也就是说,「这个构件在平面图上长什么样」是每种节点自己回答的,不是有一个中心化的投影器把三维网格压扁。这个取向决定了后面所有事情:平面图不是三维的一个视图,而是一套平行的、由构件自己声明的二维几何。FloorplanGeometry 是个联合类型,kind 覆盖了 path、polygon、polyline、rect、circle、line、text、image、group 这些通用图元,也覆盖了 dimension、dimension-string、dimension-label、equal-spacing-badge 这些纯图纸概念,还有 endpoint-handle、midpoint-handle、edge-handle、move-handle、move-arrow、rotate-arrow 这些纯交互概念。三类东西混在同一个联合类型里,这是后面每一处过滤逻辑存在的根本原因。
二、谁决定图上出现什么:两档模式与八类注释
packages/editor/src/lib/floorplan/floorplan-mode.ts 是这一层的裁决者。这个文件很短,通篇只有几个纯函数,没有状态、没有副作用,却决定了导出的图长什么样。
模式只有两档:FLOORPLAN_MODES = ['default', 'expert'],默认 default。normalizeFloorplanMode 只把严格等于 'expert' 的值认成专家档,其余一律回落。另一个 normalizeFloorplanModesByProject 按项目 ID 分别记模式,说明模式跟着项目走而不是全局开关。
真正有意思的是 resolveFloorplanAnnotationVisibility。注释可见性一共八类,定义在同目录的 annotation-visibility.ts:automaticDimensions、contextualDimensions、manualDimensions、measurements、openingMarks、structuralGrids、roomLabels、stairAnnotations。专家档下这八个开关直接用用户存的那份,默认档下则被一段硬编码覆盖:
const interactive = context.target === 'editor'
const selected = interactive && context.selected === true
const visibility: FloorplanAnnotationVisibility = {
automaticDimensions: false,
contextualDimensions: selected,
manualDimensions: selected,
measurements: selected,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
}
注意 context.target 的两个取值:'editor' 和 'export'。默认档下,那三个跟着 selected 走的类别只有在编辑器里选中构件时才现身;而导出时 target 是 'export',interactive 恒为 false,于是 selected 恒为 false。结论很硬:默认档导出的 PDF 上,八类注释里只剩房间标签。这不是 bug,是这一档的产品判断——默认档给的是干净的房间布局图,专家档才是带标注的施工向图纸。但如果你不知道这个,会以为导出坏了。
同一个文件里还有一处默认档的强制覆盖:
export function resolveFloorplanWallDimensionReference(
mode: FloorplanMode,
expertReference: FloorplanWallDimensionReference,
): FloorplanWallDimensionReference {
return mode === 'default' ? 'centerline' : expertReference
}
FloorplanWallDimensionReference 的三个取值在 floorplan-extension.ts 里:'finished-faces' | 'centerline' | 'stud-faces',默认常量是 'finished-faces'。这是墙尺寸的标注基准——量到墙的完成面、量到墙中心线、还是量到龙骨面。同一道墙,三种基准量出来的数不一样,差的就是构造层厚度。默认档一律按中心线,专家档才尊重你的选择。对着导出的图核对尺寸之前,先确认你在哪一档。
annotation-visibility.ts 里的 filterFloorplanAnnotationGeometry 负责执行:递归走几何树,读每个节点的 annotationRole(role 会向子节点继承),发现该角色对应的类别关着就整棵剪掉;group 的孩子全被剪光时 group 自己也返回 null。有个细节值得学——孩子一个没变时它返回原对象而不是新建,省掉一次无谓的引用变更。
三、导出怎么做的:离屏量一遍,再用 PDF 重画一遍
导出的编排在 packages/editor/src/lib/floorplan/floorplan-export.tsx,入口是 exportFloorplanPdf(scope),scope 只有两个值:'full' 和 'structure'。后者只保留 category === 'structure' 的节点,前者保留所有有平面图构建器且可见的节点。
这条链路最值得看的是它并没有直接把几何算成 PDF 坐标,而是走了一段绕路:
第一步,resolveExportLevels 找出要导的楼层。它先看当前选中的楼层,回溯到所属建筑,把建筑下所有 level 子节点按楼层号从下往上排;没有建筑包裹时退化成单层。楼层标题取节点名,没名字就是 Level N。
第二步,collectFloorplanGeometry 逐节点跑构建器。几个容易忽略的动作:resolveFloorplanExportViewState 造了一份中性视图状态——选中、悬停、高亮、移动全是 false,配色换成中性色板,免得屏幕上的选中态被打进纸里;按 floorplanLayerRank 排序,因为文档顺序就是绘制顺序,区域得压在墙和楼板下面;再通过 collectFloorplanLinkedLevelNodes 把跨层关联的节点拉进来,用 parentOverride 把它们的父节点换成当前层。
第三步是绕路的核心:mountFloorplanSvg 把这堆几何真的用 React 渲染成一棵 SVG,挂在 position:fixed;left:-10000px 的离屏容器里,用 flushSync 强制同步提交,再 await nextFrames(2) 等两帧让异步加载的图标图片进入测量范围,然后调 getBBox()(SVG 元素的实际包围盒)拿边界。为什么不直接算?文字宽度、图标尺寸、描边外扩这些东西,算不如量准。代价是这条链路强依赖浏览器 DOM。
拿到边界后,resolveFloorplanExportViewport 加一圈留白:padding = max(1, max(宽, 高) * 0.2),米为单位;resolveFloorplanScreenUnitsPerPixel 取 max(模型宽/框宽, 模型高/框高),这个比值反过来喂给注释层,让注释按最终纸面尺寸排布;resolveSvgAnnotationCollisions 在离屏 SVG 上做一次标签避让,结果不是以返回值传出来的,而是被写进 DOM:代码把带 data-floorplan-annotation-label 标记的元素挨个查出来,从它们的 data-floorplan-annotation-layout-dx 与 data-floorplan-annotation-layout-dy 上读出偏移量,非有限数一律按 0 处理,最后按查询顺序存成 annotationLabelShifts 数组。这个「算在 DOM 上、读回内存里」的中转,是后面第五节那条顺序坑的来源。
第四步才是画 PDF。页面固定 A4 横向,页边距 36pt、标题带 28pt(pt 是排版磅,1pt 等于 1/72 英寸)。renderFloorplanGeometryToPdfKit 被调用两次,一次 annotationLayer: false 画模型,一次 true 画注释——两层的线宽和字号规则完全不同。
整条链路的分工可以这样速查:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 模式裁决 | 把 default/expert 两档翻译成八类注释的开关,并在默认档强制墙尺寸基准 | packages/editor/src/lib/floorplan/floorplan-mode.ts | 导出的图上标注莫名其妙少了,或墙尺寸和你以为的基准对不上 |
| 注释类别与几何裁剪 | 定义八类注释与角色映射,按角色递归剪掉整棵不该出现的几何子树 | packages/editor/src/lib/floorplan/annotation-visibility.ts | 想加一类新标注,或想让某类标注跟着另一类一起消失 |
| 导出编排 | 逐层收几何、离屏挂 SVG 量边界、算铺页、分两趟交给 PDF、拼明细表 | packages/editor/src/lib/floorplan/floorplan-export.tsx | 某层没被导出、图被裁边、明细表分页断得难看 |
| PDF 矢量绘制 | 把每种几何 kind 画成 PDF 原生矢量与文字,处理注释层的定磅缩放 | packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts | 某类图元屏幕上有、PDF 里没有 |
| PDF 文档外壳 | 包一层类 jsPDF 的接口,动态引入 pdfkit 与 blob-stream,最后走浏览器下载 | packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts | 关心导出是否出网、关心首屏包体积 |
| 图种与标签微调 | 存当前图种和你手动拖过的标签偏移 | packages/editor/src/store/use-drawing-view.ts | 标签压字,你挪过之后想让它别再跑回去 |
顺带一提,packages/editor/src/lib/floorplan/ 目录里,模式裁决、注释可见性、导出编排、PDF 渲染这几个吃分支最多的文件,都各自配了一个同名的 .test.ts;而坐标换算、几何工具这类薄文件则没有。哪些文件被测试包住,本身就是一份「这里容易出错」的地图,读代码时可以先按这条线索定位重点。
四、别扭在哪:这个设计放弃了什么
这一节是本篇的重点。三维数据当二维图纸用,代价是具体的、可以指着代码说的。
没有出图比例。 fitPlanToBox 做的是等比缩放居中铺满页框:算出宽高比,先按框宽铺,超高了再按框高退。也就是说每一页的比例尺取决于那一层的图形有多大。1:50、1:100 这种建筑图纸赖以生存的固定比例,这条链路里不存在。图打出来能看、能量相对关系,但拿尺子在纸上量再乘比例这套用法不成立。
注释的尺寸不跟着世界坐标走。 floorplan-pdfkit-renderer.ts 里有一堆磅值常量:尺寸线 0.5pt、终止符 0.75pt、尺寸文字 8pt、房间号 7pt、房间明细 5.5pt。渲染时它们统统乘以 unitsPerPoint(即 1 / pointsPerUnit)换算回模型坐标系,效果是不管图缩到多小,注释在纸上永远是那个磅值。这是正确的图纸行为,但意味着模型缩得越小,注释在图上占的相对面积越大,小房间会被自己的标注淹掉。
二维排版信息没有独立通道,只能反推。 最能说明问题的是 annotationTextSizePt:
case 'room-label':
if (geometry.fontSize >= 0.18) return DEFAULT_ANNOTATION_FONT_SIZE_PT
if (geometry.fontSize >= 0.145) return ROOM_NUMBER_FONT_SIZE_PT
return ROOM_DETAIL_FONT_SIZE_PT
房间标签在纸上分三级:房间名、房间号、房间明细。但几何里没有「这是第几级」这个字段,只有一个以米为单位的 fontSize。于是代码拿 0.18 和 0.145 两个阈值把米制字号反推成层级。同类的还有 drawDimensionLabel 里的文本宽度估算:geometry.text.length * 6.2 * unitsPerPoint,按字符数乘常数估宽,用来画标签底板。这些不是写得不好,是三维几何里确实没地方放这些信息。
字体族被收敛成两个。 drawNativeText 里,字体名含 mono 或 courier 的一律走 Courier,其余一律 Helvetica;字重只区分粗与不粗(数值 ≥500 算粗)。屏幕上的字体在纸上不保证还原。
没被 switch 覆盖的 kind 会静默消失。 renderGeometry 的 switch 显式处理了 path、polygon、polyline、rect、circle、line、text、dimension、dimension-string、dimension-label、equal-spacing-badge、image、group,剩下的走 default: return。而 FloorplanGeometry 的联合类型里还有 hatch、hit-line 这些 kind。屏幕上有、PDF 里没有,且不报错。
交互图元被显式剔除。 FLOORPLAN_EXPORT_EDITING_KINDS 这个集合列了六种手柄与箭头 kind,导出时直接丢掉。这个设计的隐含约定是:任何做成手柄类 kind 的东西都别指望出现在纸上。
图片失败是静默的。 drawImage 整个包在 try/catch 里,loadAssetUrl 返回空、fetch 不 ok、blob 读失败,全都 return,图上就少一个图元,控制台没有任何提示。
它明确不管的事:不生成图框和标题栏,页眉只有一行 ${楼层名} - ${图种名};不做审图、不做规范校验;不产出 DXF 这类矢量交换格式——packages/mcp/src/tools/ 下的 export-glb.ts 与 export-json.ts 是另一条面向数据交换的路,跟图纸这条不是一回事。另外 DRAWING_TYPE_OPTIONS 里虽然列了五种图种(floor-plan、foundation-plan、reflected-ceiling-plan、roof-plan、site-plan,其中反射顶棚平面图是抬头看天花板、但按平面图方向画的那种图),但 use-drawing-view.ts 里的状态类型被 Extract<ConstructionDrawingType, 'floor-plan'> 锁死在楼层平面图一种,其余目前是留的口子。
五、上手与避坑清单
先切专家档再导出。 会踩是因为默认档下 target: 'export' 让 selected 恒为 false,自动尺寸、门窗开口标记、结构轴网、楼梯标注四类直接关掉,你只会看到房间标签,很容易误判成导出功能坏了。避法:导之前确认模式,或者干脆把「默认档导出=无标注布局图」当成一个已知形态接受下来。
核对尺寸前先确认基准。 会踩是因为默认档把墙尺寸基准强制成中心线,而配置里的默认常量是完成面。同一道墙两种基准的数差一个构造层厚度,看上去像是模型建错了。避法:拿导出的数去对施工尺寸之前,先去 resolveFloorplanWallDimensionReference 确认当前档位实际用的是哪个。
新增几何 kind 时同步改渲染器。 会踩是因为屏幕渲染器和 PDF 渲染器是两套代码,联合类型加了新 kind,TypeScript 在 default: return 的 switch 上不会报错,于是新图元在编辑器里好好的、导出后人间蒸发。避法:加 kind 时把 floorplan-pdfkit-renderer.ts 的 switch 一起过一遍,把「屏幕有、纸上没有」当成必查项。
别改动两趟遍历的顺序。 会踩是因为标签避让的结果是一个按顺序消费的数组:nextAnnotationLabelShift 每被调一次就把索引加一,取的是 annotationLabelShifts[index]。这个数组在离屏 SVG 里按 DOM 顺序采集,在 PDF 里按遍历顺序消费,两边顺序必须一致。任何改变注释渲染次序的改动,都会让所有标签的偏移整体错位,而且错得很像「布局算法不准」。避法:动排序或动注释产出顺序时,把两侧一起改,并优先补测试而不是肉眼看 PDF。
离屏渲染依赖真实浏览器。 会踩是因为 getBBox()、requestAnimationFrame、createRoot 这一套在无头环境里要么不存在要么行为不同,想把导出搬到服务端会直接卡在测量这一步。避法:把这条链路当成纯客户端能力对待,需要服务端出图就得另起一套几何测量,而不是复用它。
明细表也会独立占页。 明细表就是建筑图纸里那种把同类构件按行列出来的表格,最常见的是门窗表、房间表,一行一个构件,列是编号、尺寸这类属性。会踩是因为 exportFloorplanPdf 的判断是几何和明细表都为空才跳过这一层——某层没有任何可画构件、但有构件贡献了明细表(FloorplanSchedule 结构里带 columns、rows,还有可选的 issues),照样出页,而且 issues 会以 WARNING: 前缀的橙色文字印在表格上方。这里的 issues 是数据完整性提示而不是规范审查,packages/nodes/src/zone/room-documentation.ts 里能看到它检的是哪几类事:房间没填编号、编号重复、标了「封闭」但没被证明封闭。避法:把「图纸页数 ≠ 楼层数」写进你的预期,做自动化校验时按标题匹配而不是按页序。
六、绕不开的安全边界
这个仓库不止是个编辑器,packages/mcp/ 下是一套让 Agent 直接改模型的 MCP 服务器,packages/mcp/src/tools/ 里既有只读的查询工具,也有一整排写操作工具:create-wall.ts、cut-opening.ts、place-item.ts、delete-node.ts、apply-patch.ts 都在,另外还有 undo.ts、redo.ts 这类改动历史工具。也就是说,接上之后 Agent 有权新建、开洞、摆件、删节点、打补丁——这不是只读接口。
数据落在哪也写得很清楚:packages/mcp/src/storage/index.ts 的注释说明默认写入 ~/.pascal/data/pascal.db,可以用 PASCAL_DB_PATH 指定精确文件、用 PASCAL_DATA_DIR 指定目录。这是你本机上的一个文件,谁能读你的 home 目录谁就能读你的模型。
出网这件事也真实存在:packages/mcp/src/lib/safe-fetch.ts 是为视觉类工具(比如 tools/vision/analyze-floorplan-image.ts)准备的取图通道,注释里明确列了它挡掉的东西——回环地址、链路本地地址(含云元数据的 169.254.169.254)、私有网段、非 http(s) 协议,以及重定向每一跳都重新校验,并有体积与超时上限,可用 PASCAL_ALLOWED_ASSET_ORIGINS 配白名单。这段注释还诚实记了一句:早先这几个工具是直接裸 fetch(url) 的。这类防护存在本身,恰好说明「Agent 拿着你给的 URL 去取图」是这套工具的常规动作,接入前值得按 MCP 的安全边界 和 最小权限怎么划 里的思路先划一遍范围。
仓库 README 是这样定位自己的:用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器;许可证是 MIT(Copyright 2026 Pascal Group Inc.)。仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件,wiki/architecture/ 下有 20 份 md(1 份 README 索引加 19 篇分主题),整个 monorepo 是 apps/ 2 个加 packages/ 9 个。
收尾:接下来该读哪个文件
如果你只想验证本篇的说法,按这个顺序读最省时间:floorplan-mode.ts 看谁在裁决,annotation-visibility.ts 看裁决怎么被执行,floorplan-export.tsx 从 exportFloorplanPdf 往下追一遍编排,最后 floorplan-pdfkit-renderer.ts 的 renderGeometry 那个 switch——那个 switch 覆盖了哪些 kind、漏了哪些 kind,基本就等于回答了「什么东西能上纸」。
给自己留三个自检问题:你的构件在默认档下会不会整个消失,如果会,用户第一次导出看到的是什么;你产出的几何有没有 annotationRole,没有的话它永远归在模型层、跟着注释开关一起消失不了;你有没有依赖任何在纸上必须定磅的东西,如果有,它是不是也乘了 unitsPerPoint。
最后一句判断:这套做法适合「模型即图纸」的快速沟通场景——把当前设计状态变成一份能发给人看的 PDF。它不适合替代正式出图流程,因为固定比例、图框标题栏、规范校验这三样它都不提供,而这三样恰好是正式图纸之所以是图纸的原因。分清这条界线,用起来就不别扭了。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 开源 3D 建筑编辑器 Pascal Editor 的测量体系拆解 和 3D 建筑编辑器 Pascal Editor:IFC 模型转换必然有损。