Technical note

RangeTable 语义映射

记录 CAD/CAE Viewer 合批显示之后,如何通过 RangeTable 在 Drawable、primitive index 和 CAD 拓扑对象之间建立映射,让拾取、工程树联动、高亮、显隐和局部重建仍然保留语义。


背景:合批之后,Drawable 不再是一个 face

在旧 Viewer 里,如果一个 CAD face 对应一个 Geode 或 Drawable,拾取逻辑会很直接。

鼠标点到某个显示节点,节点上绑定了 objectId、faceTag 或 edgeTag。Viewer 只要读出这些标识,就能知道用户点中了哪个拓扑对象。

合批之后,这个假设就不成立了。

为了减少 Geode、Drawable 和 StateSet 数量,多个 face 会被合并到一个批量 Geometry 里。多个 edge 也可能被合并成一个或少量边线 Geometry。

此时,一个 Drawable 里可能包含很多 CAD face 或 edge。

这会带来一个直接问题:

OSG 拾取结果:
  drawable + primitiveIndex

CAD/CAE 交互需要:
  objectId + bodyId + faceTag / edgeTag

中间缺了一层映射。

RangeTable 就是为了解决这个问题。

它的作用不是渲染,而是在合批显示之后,把 OSG 的绘制结果重新解释回 CAD/CAE 所需要的拓扑语义。

合批打破了节点和语义的一一对应

旧结构里,显示节点和拓扑对象通常是一一对应的:

FaceGeode_102
  -> objectId = A
  -> faceTag = 102

这种结构虽然在大模型上容易产生节点爆炸,但语义关系很直观。

合批之后,结构变成了另一种形式:

DisplayBatch_01
  -> Geometry
      -> primitive 0..15    belong to face 1
      -> primitive 16..40   belong to face 2
      -> primitive 41..57   belong to face 3
      -> ...

此时命中 DisplayBatch_01 并不能直接说明命中了哪个 face。还必须继续看命中的 primitiveIndex 落在哪个 range 里。

这不仅影响鼠标点击,也影响工程树联动。

工程树选中某个 face 时,Viewer 不能再简单找到一个 face node 然后改颜色。它需要知道这个 face 在哪个 batch、哪个 Drawable、哪段 primitive range 里,才能做高亮、显隐或局部更新。

所以 RangeTable 的核心职责是双向的:

拾取方向:
  drawable + primitiveIndex
      -> objectId / faceTag / edgeTag

业务方向:
  objectId / faceTag / edgeTag
      -> batch / drawable / primitive range

这是合批之后继续保留 CAD 交互能力的基础。

RangeTable 记录什么

RangeTable 不是简单记录一个 triangle index。

在 CAD/CAE Viewer 里,一个 range 至少需要表达几类信息:

face range:
  objectId
  bodyId
  faceTag
  batchId
  drawable
  firstPrimitive
  primitiveCount

edge range:
  objectId
  bodyId
  edgeTag
  batchId
  drawable
  firstPrimitive
  primitiveCount

其中 firstPrimitiveprimitiveCount 描述这个拓扑对象在批量 Geometry 中占据的 primitive 范围。

对于 face 来说,primitive 通常可以理解为三角形范围。

对于 edge 来说,primitive 可以理解为线段范围。

这张表看起来不复杂,但它是合批显示能继续支持 CAD 交互的关键。

如果没有它,显示层只能知道命中了一个 Geometry,却不知道命中了哪个 CAD 拓扑对象。工程树选择、面选择、边选择、局部高亮、局部显隐也都会失去语义入口。

鼠标拾取:从 primitiveIndex 找回 face

一次面拾取可以概括为:

screen position
  -> OSG intersection
      -> drawable
      -> primitiveIndex
  -> RangeTable lookup
      -> objectId
      -> bodyId
      -> faceTag
  -> PickResult

OSG 负责从屏幕坐标得到命中的 Drawable 和 primitiveIndex。

RangeTable 负责把这个结果解释成 CAD 语义。

伪代码大致是:

hit = intersect(screenX, screenY)

for each intersection:
    range = rangeTable.findFaceRange(hit.drawable, hit.primitiveIndex)

    if range exists:
        return {
            objectId: range.objectId,
            bodyId: range.bodyId,
            faceTag: range.faceTag,
            worldPoint: hit.worldPoint
        }

edge 拾取也是类似过程,只是查的是 edge range。

对于一些边显示策略,Viewer 还可能有基于屏幕距离的补充判断,但 RangeTable 仍然是批量 edge Geometry 的语义入口。

这里有一个工程细节很重要:拾取结果不能只看“命中了哪个 Drawable”,还必须确认命中的 primitive 当前是否仍然有效、可见、没有被局部替换或隐藏状态排除。

否则用户可能点到已经隐藏的对象,或者返回错误的拓扑对象。

这类问题在旧结构里不明显,因为节点本身就是对象边界;合批之后,显示状态和语义映射必须一起维护。

工程树选择:从 faceTag 找到显示范围

鼠标拾取是从显示结果反查业务语义。

工程树选择则是反过来。

当用户在工程树中选中某个 object、body、face 或 edge 时,Viewer 已经知道业务标识。此时需要找到它对应的显示范围。

流程可以理解为:

objectId + faceTag
  -> RangeTable lookup
      -> batchId
      -> drawable
      -> firstPrimitive / primitiveCount
  -> highlight / hide / isolate / style override

对于 body 或 object 级选择,还需要先展开为它包含的 face 和 edge,再逐个查 range 或 bucket。

这一步让工程树联动不再依赖“一个 face 一个节点”。

即使一个 Geometry 里合并了很多 face,Viewer 仍然可以定位到某个 face 的范围,并在 Selection Layer 中生成高亮,或者在显隐逻辑中定位它所在的 DisplayBucket。

这也是合批后能继续保留 CAD 操作体验的原因。

显隐和高亮也依赖 RangeTable

RangeTable 最直观的用途是拾取,但它不只服务拾取。

显隐需要它。

隐藏某个 face 或 body 时,Viewer 需要知道目标落在哪些 batch 或 bucket 中。完整 bucket 可以直接切状态;局部目标可能需要更新颜色数组、索引、临时 batch,或者触发局部重建。

高亮也需要它。

Selection Layer 可以根据业务选择目标找到相关 face / edge,再构建独立高亮 batch,或者对已有批次做受控更新。

没有 RangeTable,合批后的高亮很容易退化成整批高亮,语义就不对。

样式恢复也需要它。

比如清除选择、ShowAll、ResetStyle 这类操作,要知道哪些显示范围曾经被局部修改,哪些基础 range 需要恢复,哪些对象只是临时覆盖。

所以 RangeTable 可以理解为合批显示后的语义索引。

它不负责渲染,但它决定了渲染结果能不能被 CAD/CAE 交互正确解释。

它和普通 mesh index 映射不一样

普通 mesh viewer 也可能有 triangle index 映射。

例如点击第几个三角形,返回 mesh triangle id。

但 CAD/CAE Viewer 的 RangeTable 要处理的问题更复杂。

第一,它映射的目标不是 triangle,而是拓扑语义。

多个 triangle 属于一个 CAD face。一条 CAD edge 可能对应多个 line segment。用户关心的是 faceTag、edgeTag、bodyId,而不是第几个三角形。

第二,它需要支持双向查询。

拾取时从 Drawable + primitiveIndex 反查 face;工程树选择时从 faceTag 反查显示范围。

第三,它要配合显示状态。

隐藏、隔离、透明、选择高亮、局部替换都会影响当前可见性。RangeTable 不能只记录初始 mesh 索引,还要和当前显示批次保持一致。

第四,它需要面对批次变化。

空间分桶、材质分桶、局部重建、临时高亮 batch 都可能改变 Geometry 和 batch 结构。RangeTable 必须跟着更新,或者至少能明确区分基础范围、临时范围和已失效范围。

所以它不是一个简单的:

triangleId -> faceId

更准确地说,它是 CAD 语义和批量渲染数据之间的索引层。

空间分桶下的一致性问题

引入 DisplayBucket 后,RangeTable 的职责会更重。

一个对象的 face 可能分布在多个 bucket 中。

一个 body 的选择可能跨多个 bucket。

某个 bucket 局部重建后,它里面的 Drawable、primitive range 或 batchId 可能发生变化。

这意味着 RangeTable 需要回答几个问题:

这个 face 当前在哪个 bucket?
这个 bucket 的 Geometry 是否被替换过?
当前 range 是基础 range,还是局部更新后的 range?
ShowAll 或 ResetStyle 时,基础显示范围如何恢复?

这些问题不适合在公开文章里展开到具体实现细节,但工程上必须正视。

只要 Geometry 可以局部重建,RangeTable 就必须有一致性策略。

否则局部重建后的模型可能看起来正确,但拾取、高亮、显隐会出错。

这种问题在显示引擎里很隐蔽:画面是对的,不代表语义索引也是对的。

Selection Layer 下的边界

选择高亮也会让 RangeTable 面临边界问题。

理想情况下,Selection Layer 是覆盖层,不应该污染基础显示。

例如选中一个 face 时,可以生成一个独立高亮 Geometry,或者对一小段显示范围做受控更新。

但不管采用哪种策略,都要保持基础语义清晰:

基础 batch 的 RangeTable 仍然描述真实模型;
高亮 batch 不应该破坏基础拾取;
hover 和 selection 的临时显示要能及时清理;
大范围选择时,降级策略不能让语义结果变错;
隐藏对象后,拾取不能再返回被隐藏目标。

所以 RangeTable 和 Selection Layer 的关系更像是:

RangeTable 提供稳定语义;
Selection Layer 消费这些语义来构造视觉反馈。

选择层可以变化,但基础映射不能乱。

如果选择高亮直接修改基础 Geometry,又没有同步 RangeTable 和显示状态,就很容易出现状态污染。

这也是为什么合批之后,selection、highlight、base display 最好分开管理。

RangeTable 的边界和代价

RangeTable 也不是没有代价。

首先,它增加了内存和构建成本。

每个 face、edge 都需要记录 range。大模型下,这张表本身也需要控制布局和索引效率。

其次,它增加了一致性维护成本。

批次重建、局部替换、隐藏恢复、ShowAll、ResetStyle 都可能影响 range。显示引擎需要明确什么时候更新、什么时候恢复、什么时候保留基础 range。

再次,它让调试复杂了一层。

以前点不到对象,可能只看节点 mask。现在还要检查 DisplayData、Batch、Drawable、primitive range、可见性过滤和拾取模式。

最后,它要求显示引擎有清晰的职责边界。

RangeTable 不应该变成业务数据库,也不应该承担所有选择状态。它只负责显示 primitive 和 CAD 语义之间的映射。

选择集合、持久样式、隐藏状态、业务树结构,应该由各自模块维护。

这样 RangeTable 才不会从“语义索引”变成另一个难以维护的全局状态表。

小结

OSG 合批解决了节点和 Drawable 数量问题,但也打破了旧结构里“一个 Drawable 就是一个 CAD face / edge”的便利假设。

RangeTable 是这个问题的补偿机制。

它把 objectId、bodyId、faceTag、edgeTag 和 batch、Drawable、primitive range 关联起来,让合批后的 Geometry 仍然能参与 CAD/CAE 交互。

鼠标拾取靠它从 Drawable + primitiveIndex 找回拓扑对象。

工程树选择靠它从业务对象定位显示范围。

显隐、高亮、局部重建和空间分桶也都需要它提供稳定语义入口。

所以 RangeTable 不是一个附属优化,而是 CAD/CAE Viewer 合批显示的基础设施。

没有它,合批只能解决渲染数量问题。

有了它,显示引擎才有机会同时保留性能和工程语义。

下一步,问题会继续往交互显示上延伸:当 RangeTable 能找回 face 和 edge 之后,选择、高亮、hover、框选这些临时状态,应该怎么和基础显示状态分开管理。