Technical note
CAD 拾取系统设计
记录 CAD/CAE Viewer 中拾取系统的工程设计:为什么拾取不能只返回 triangle 或 Drawable,以及如何结合 RangeTable、选择模式、可见性过滤、DisplayBucket 和 Selection Layer 找回 CAD 拓扑语义。
背景:CAD 拾取不是点到一个三角形
在普通 3D Viewer 里,拾取可以很简单:鼠标点到哪个 triangle,就返回哪个 triangle;或者命中哪个 Drawable,就返回哪个 Drawable。
但 CAD/CAE Viewer 不能只做到这一步。
用户点击模型,不是为了知道“第几个三角形被点中”,而是为了知道:
点中了哪个 object;
点中了哪个 body;
点中了哪个 face;
点中了哪条 edge;
是否点中了 vertex;
当前选择模式下应该返回哪一类对象;
这个对象是否可见、是否被隐藏、是否在隔离范围内。
这些都是拓扑语义,而不是渲染语义。
OSG 拾取能给出渲染命中结果,但 CAD 交互需要把这个结果解释回工程对象。
拾取系统的核心工作,就是把“屏幕命中”转换成“当前选择模式下的 CAD 目标”。
如果这一步没有处理好,后面的选择高亮、工程树联动、显示隐藏、测量、修复都会跟着出问题。
OSG 给出的结果还不够
OSG 的线段相交拾取通常能拿到这些信息:
drawable
primitiveIndex
worldPoint
这对渲染层已经足够。
Drawable 表示命中了哪个可绘制对象,primitiveIndex 表示命中了哪个 primitive,worldPoint 表示命中点的世界坐标。
但合批显示之后,一个 Drawable 里可能包含很多 CAD face。edge 显示也可能是很多拓扑边合并成一个 Geometry。
此时 Drawable 不再等于业务对象。
CAD 交互真正需要的是:
objectId
bodyId
faceTag
edgeTag
vertexTag
selectionTargetType
worldPoint
所以拾取系统必须做一次语义解析:
screen point
-> OSG intersection
-> drawable + primitiveIndex
-> RangeTable
-> CAD selection target
没有这一步,合批显示虽然能画出来,但用户点到模型时,Viewer 不知道应该选中哪个 CAD 对象。
这也是 CAD 拾取系统和普通 mesh viewer 拾取的第一层差异。
RangeTable:从 primitive 找回拓扑
RangeTable 是合批拾取的关键。
在构建 DisplayBatch 时,显示引擎会记录每个 face 或 edge 在批量 Geometry 中对应的 primitive 范围:
face range:
objectId
bodyId
faceTag
drawable
firstPrimitive
primitiveCount
edge range:
objectId
bodyId
edgeTag
drawable
firstPrimitive
primitiveCount
拾取时,OSG 返回 drawable 和 primitiveIndex。
RangeTable 判断这个 primitiveIndex 落在哪个 range 中,再返回对应的 face 或 edge 语义。
简化流程是:
hit = osgPick(screenX, screenY)
range = rangeTable.find(hit.drawable, hit.primitiveIndex)
if range exists:
return PickResult(
objectId = range.objectId,
bodyId = range.bodyId,
faceTag = range.faceTag or -1,
edgeTag = range.edgeTag or -1,
worldPoint = hit.worldPoint
)
这样,显示层可以合批,交互层仍然能得到 CAD 语义。
RangeTable 的价值不只在面拾取。边拾取、工程树反向定位、高亮、显隐和局部重建都会用到它。
拾取系统只是它最直接的使用场景。
选择模式会改变拾取结果
CAD/CAE Viewer 里,用户点击同一个位置,不同选择模式下应该返回不同目标。
例如点到一个面:
face 模式:
返回 face。
body 模式:
返回这个 face 所属的 body。
object 模式:
可能提升为 object 或 body。
edge 模式:
优先尝试 edge。
vertex 模式:
可能走屏幕距离或特征点拾取。
所以拾取系统不能只是“命中什么返回什么”。
它需要理解当前 selection mode。
一个简化逻辑是:
if mode == Face:
return picked face
if mode == Edge:
try edge pick
fallback to edge screen-distance pick if needed
if mode == Body:
pick face first
promote face -> body
if mode == Object:
pick face or edge
promote result -> object / body / owner face
if mode == Vertex:
use vertex-specific picking
这里的“提升”很重要。
用户点到的几何 primitive 可能属于一个 face,但在 object 模式下,用户真正想选的是对象或 body。拾取系统需要根据 DisplayData 中的归属关系,把底层命中提升到当前模式需要的目标。
这也是 CAD 拾取和普通 mesh 拾取不同的地方。
普通 mesh viewer 很少需要把 triangle 提升成 body 或工程对象。
面、边和顶点的拾取差异
面拾取通常最稳定。
因为 surface Geometry 是主要显示对象,OSG intersection 能直接命中三角形,再通过 RangeTable 反查 face。
边拾取更复杂。
CAD edge 通常是独立的拓扑边显示层。它可能有自己的显示开关、简化策略和拾取 mask。某些情况下,edge 渲染为了性能会简化显示,但交互仍然希望能选到真实拓扑边。
因此边拾取可能有两类路径:
edge render pick:
命中 edge Geometry
通过 RangeTable 返回 edgeTag
screen-distance pick:
在屏幕空间找最近 edge
作为 fallback 或特定模式路径
顶点拾取又不同。
vertex 往往不是主要渲染对象,可能更适合用屏幕距离、候选点过滤或特征点策略。
边中点、面中心、端点、圆心等特征点拾取,也是 CAD 交互常见扩展方向。
它们可以建立在同一套 selection mode 和候选过滤框架上,但不一定都依赖 OSG primitive 命中。
这类扩展不适合在公开文章里展开具体算法,但工程方向是清楚的:拾取系统要输出稳定的 CAD 目标,而不是暴露底层渲染命中细节。
工程树选择不是屏幕拾取
工程树选择和鼠标拾取方向相反。
鼠标拾取是:
screen point
-> drawable / primitiveIndex
-> CAD target
工程树选择是:
tree item
-> CAD target
-> display range
-> highlight / isolate / visibility action
工程树已经知道 object、body、face 或 edge。此时不需要 OSG intersection,而是需要根据业务目标找到对应的 DisplayData、RangeTable range 或 DisplayBucket。
这意味着拾取系统和工程树联动虽然入口不同,但最终都要统一到 selection target 上。
统一目标结构很重要。
否则点击得到的是一种结果,工程树选择得到的是另一种结果,Selection Layer、显隐和属性面板就会分裂成多套逻辑。
从工程上看,鼠标拾取、工程树选择、框选、Ctrl+A 最终都应该尽量落到同一种选择目标描述上。
这样后续高亮、隐藏、隔离和属性同步才能复用同一套路径。
框选和单点拾取也不同
单点拾取通常关心最近命中点。
框选则关心屏幕区域内的一组目标。
框选需要处理更多问题:
当前 selection mode 是 face、edge、body 还是 object;
是否只选择可见对象;
如何去重;
body / object 模式下如何从 face 命中提升;
大量目标如何交给 Selection Layer 高亮;
是否需要限制候选范围,避免全场景扫描。
大模型下,框选尤其容易变重。
如果每次框选都扫描所有 face 或 edge,交互会很慢。因此框选通常需要结合可见 bucket、屏幕范围过滤和选择模式缩小候选。
框选的结果也不应该直接等于高亮 Geometry。
它应该先产出 selection targets,再由 Selection Layer 决定如何显示:完整高亮、按 bucket 批量高亮,还是大范围 hint。
这也是前面几篇里一直强调的边界:拾取系统负责找到目标,Selection Layer 负责把目标变成视觉反馈。
hover:高频但不能重
hover 是拾取系统里最容易被低估的部分。
点击是低频操作,用户点一下可以接受短暂处理。hover 是高频操作,鼠标移动过程中会不断触发。如果 hover 每次都做完整拾取、高亮构建和状态切换,Viewer 会很快变得卡顿。
hover 需要几个原则。
第一,要节流。
鼠标移动非常频繁,没必要每个事件都完整处理。
第二,要候选过滤。
优先利用当前可见 bucket、选择模式和拾取 mask,减少不必要的全场景命中。
第三,高亮要轻。
hover 反馈通常只需要 outline、edge highlight 或小的临时 batch,不应该触发重型重建。
第四,要及时清理。
鼠标移开后,旧 hover 高亮必须清掉;切换选择模式、隐藏对象、重建场景时,也要清理旧 hover 状态。
hover 的目标不是“每次都最精确地重新构建显示”,而是在不打断交互的前提下给出足够稳定的视觉反馈。
这一点和工程软件的使用体验关系很大。
点击慢一点,用户还能理解;鼠标移动时一直卡,就会直接影响整个 Viewer 的手感。
可见性过滤不能省
拾取系统必须理解 visibility state。
合批之后,一个 Drawable 里可能还有被隐藏的 face。即使 OSG intersection 命中了某个 primitive,也不能直接认为它是有效选择目标。
还要判断这个 face 或 edge 当前是否可见,是否被隐藏,是否在隔离范围内。
否则会出现很糟糕的交互问题:
画面上看不到的对象仍然能被点中;
隔离模式下点到隔离范围外的对象;
ShowAll 之前的隐藏状态影响拾取;
局部重建后旧 range 仍被当成有效命中。
所以拾取结果通常需要经过可见性过滤:
hit range
-> build selection target
-> check visibility state
-> accept or skip
这一步不是性能优化,而是正确性要求。
显示状态和拾取语义必须一致。画面上不可见的对象,不应该在正常交互中被当成有效目标。
大模型下的候选范围
大模型拾取不能总是对整场景做同样的工作。
DisplayBucket 可以帮助缩小候选范围。
比如 hover、框选和 edge fallback 可以优先考虑可见 bucket 或交互候选 bucket,而不是扫描全部数据。
selection mode 也能缩小范围。
face 模式优先面,edge 模式优先边,vertex 模式走顶点策略。不同模式使用不同拾取路径,能减少不必要的尝试。
RangeTable 提供 primitive 到语义的快速入口。
visibility state 用于过滤隐藏目标。
Selection Layer 负责后续显示反馈。
拾取系统本身不应该承担高亮构建,也不应该直接修改基础显示。
可以理解为:
Picking:
负责找到目标。
Visibility:
负责判断目标是否当前有效。
RangeTable:
负责把渲染命中转成 CAD 语义。
Selection Layer:
负责把目标变成视觉反馈。
DisplayBucket:
负责缩小候选和局部更新范围。
这些边界清楚后,大模型拾取才不容易演变成一团互相调用的逻辑。
fallback 是工程现实
理想情况下,所有拾取都可以通过渲染命中和 RangeTable 精确完成。
但 CAD/CAE Viewer 里经常需要 fallback。
比如 edge 显示可能为了性能被简化,或者某些边在当前视图里很细,直接 OSG 命中不稳定。此时可以使用屏幕距离找到最近 edge。
顶点拾取也类似。
用户想选的是拓扑点或特征点,而不是某个被渲染出来的大 primitive。用屏幕距离和候选过滤可能更符合交互预期。
面中心、边中点、端点、圆心等特征点拾取,也可以作为扩展方向。它们并不一定依赖 Drawable + primitiveIndex,而是依赖 DisplayData、拓扑关系和屏幕空间判断。
fallback 的重点是受控:
服务当前选择模式;
尊重可见性;
不要在大模型下退化成全量扫描;
返回结果要能进入统一 SelectionTarget。
fallback 不是给拾取系统开后门,而是在渲染命中不稳定时,仍然保持交互语义稳定的一条补充路径。
拾取系统的边界
拾取系统不应该做所有事情。
它应该输出一个稳定的 PickResult 或 SelectionTarget,包含当前模式下需要的 object、body、face、edge、vertex 等语义,以及必要的 worldPoint。
它不应该直接修改颜色,不应该直接创建高亮 Geometry,也不应该把隐藏状态写进 RangeTable。
那些分别属于 Selection Layer、DisplayManager 和 visibility state。
清晰边界可以避免很多问题:
拾取负责找到目标;
Selection Layer 负责显示高亮;
Visibility 负责判断目标是否有效;
RangeTable 负责语义映射;
DisplayBucket 负责候选范围和局部更新;
工程树负责提供业务选择入口。
这样点击、hover、框选、工程树选择和 Ctrl+A 才能走到统一的 selection target,而不是各自维护一套互不兼容的逻辑。
这也是显示引擎后期越来越重要的一点:每一层都不要试图“顺手”把别的层也做了。
短期看会方便,长期一定会让状态和语义越来越难维护。
小结
CAD/CAE Viewer 的拾取不是返回一个 triangle 或 Drawable。
OSG 可以告诉我们命中了哪个 Drawable、哪个 primitive、哪个世界坐标点。但 CAD 交互需要的是 object、body、face、edge、vertex 这些拓扑语义,还要符合当前选择模式和可见性状态。
RangeTable 负责把合批后的 Drawable + primitiveIndex 反查成 face 或 edge。
selection mode 决定这个结果是否需要提升为 body 或 object。
DisplayBucket 帮助大模型缩小候选范围。
Selection Layer 负责把最终目标变成高亮反馈。
visibility state 保证隐藏和隔离后的对象不会被错误选中。
这套结构的核心,不是把拾取做得复杂,而是让每一层只负责自己的事情。
这样合批显示、大模型交互和 CAD 拓扑语义才能同时成立。
下一篇可以继续讲 Preview 临时显示。因为很多建模、修复、测量和交互命令,都不是一步完成的。它们需要在用户确认之前先把临时线、临时面、预览对象画出来,而这些 preview 对象也必须和 base display、selection layer、RangeTable 保持边界。