# u-manager

`u-manager` 是一套全面的加载器和解析器，用于从服务器路径流式传输、解密并显示结构化场景数据。支持场景、拓扑、动画、属性、相机视点、楼层、井道和设备语义模型。

所有加载器均继承自 Three.js 的 `Loader`，并提供 `setPath(path)` 方法，在调用 `loadAsync()` 前配置基础数据目录。

## `UManagerLoader`

`UManagerLoader` 是推荐的一体化入口。它会在同一路径下同时加载 `SemanticLoader` 和 `SceneLoader`：语义文件中已经存在的 `id` 会由 `SceneLoader` 自动跳过，避免建筑、楼层、设备被重复创建；非语义场景树节点仍会正常加载。返回值是一个 `UManagerSceneGroup`，可直接通过 `semanticGroup` 访问 `SemanticGroup`，通过 `sceneGroup` 访问非语义场景组。

```typescript
import { UManagerLoader } from 'u-space/plugins/u-manager';

const loader = new UManagerLoader(viewer);
loader.setPath('./scenes/my-scene');
loader.setKey('YOUR_LICENSE_KEY');

const group = await loader.loadAsync();
viewer.scene.add(group);

const semanticGroup = group.semanticGroup;
const sceneGroup = group.sceneGroup;
const facilityLayer = semanticGroup.getDefaultFacilityLayer();
const sceneLayer = sceneGroup.getDefaultSceneLayer();
```

完整可运行示例见 [`examples/test_umanager_loader.html`](https://u-space-phi.vercel.app/examples/test_umanager_loader.html)。该示例展示了如何从返回根组中直接读取 `semanticGroup` / `sceneGroup`，通过 `getDefaultFacilityLayer()` / `getDefaultSceneLayer()` 获取默认合批层，并通过 `viewer.objectManager` 获取 `SceneInstanceObject` / `FacilityInstanceObject` 后调用统一 `viewer.controls.flyToObject()`、`setInstanceHighlight()`、`setInstanceVisible()` 和 `setInstanceOpacity()`。运行时动态新增/删除 batch 实例的示例见 [`examples/test_umanager_dynamic_instances.html`](https://u-space-phi.vercel.app/examples/test_umanager_dynamic_instances.html)。

## `SemanticLoader`

加载 `db/semantic_model.json` 楼层语义数据。`loadAsync()` 返回顶层 `SemanticGroup`，用于组织整次语义解析产生的建筑、楼层以及 scene-level facility layer。每个建筑包含多个 `FloorMesh`，每个楼层把墙、柱、空间、楼梯等语义对象合并为一个 TSL 材质驱动的网格；建筑级电梯井和通风井会使用 `ExtrudeMesh` 独立渲染。语义模型包含 `Facilities` 时，`SemanticLoader` 会额外读取 `SceneMetadata.json` 指向的 `tree_models`，按场景授权配置解密，再把对应设备模型挂载到所属楼层。

```typescript
import { Vector3, Vector4 } from 'three';
import { SemanticLoader } from 'u-space/plugins/u-manager';

const loader = new SemanticLoader(viewer);
loader.setPath('./scenes/my-scene');
loader.setKey('YOUR_LICENSE_KEY'); // 官方授权场景解析 tree_models 时必需

const semanticGroup = await loader.loadAsync(); // SemanticGroup
viewer.scene.add(semanticGroup);

const building = semanticGroup.getBuildingAt(0);
const floor = building.getFloorAt(0);

building.isolateFloor(floor);
await viewer.controls.flyToObject(floor);

floor.addEventListener('click', ({ event }) => {
  const { currentTarget, intersect } = event;
  if (!intersect) return;

  const index = intersect.semanticIndex;

  currentTarget.setColorAt(index, new Vector4(1, 0, 0, 0.5));
  currentTarget.setScaleAt(index, new Vector3(1, 0.1, 1));
  viewer.render();
});
```

所有可按 ID 操作的实例都会尽量暴露统一的 `InstanceObject` API。楼层多边形语义使用轻量 `FloorSemanticInstanceObject` 代理合并几何中的一个实例；Facilities 和 SceneLoader path instancing 使用同一套模型实例化底层。业务代码优先使用 `setInstanceVisible()`、`setInstanceColor()`、`setInstanceOpacity()` 和 `viewer.controls.flyToObject(instance)`，而不是关心底层是合并几何、`InstancedMesh` 还是 fallback `Model`。

完整的继承字段、方法、颜色模式、包围盒和 dirty 同步语义见 [核心 `InstanceObject` API](./api-objects.md#instanceobject)。

`InstanceObject` 也按 Three.js 对象级包围盒约定提供 `boundingBox`、`boundingSphere`、`computeBoundingBox()` 和 `computeBoundingSphere()`。它们缓存的是实例本地坐标包围体，`Box3.setFromObject()` 会自动调用 `computeBoundingBox()` 并应用 `matrixWorld`，因此 `viewer.controls.flyToObject()` 可以直接飞向 `SceneInstanceObject`、`FacilityInstanceObject` 和楼层实例代理。需要手动处理世界包围盒时，仍可使用 `getInstanceBoundingBox(target, true)`。

### `SemanticGroup`

`SemanticGroup` 继承自 `BaseGroup`，是单次 `SemanticParser` 解析结果的顶层容器。它管理所有 `BuildingGroup`，并把跨楼层共享的 Facilities `InstancedMesh` 渲染层作为建筑的同级对象挂在 `facilityLayer`，而不是挂到某个 `BuildingGroup` 内。

建筑 ID 和设备 ID 查询使用内部 `Map` 索引维护；`getBuildingById()` 和 `getFacilityById()` 不会通过数组扫描查找语义对象。

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `buildings` | 当前语义场景内的 `BuildingGroup[]`。 |
| `facilityLayer` | scene-level 设备渲染层；同模型 URL 的静态设备会合并为 `InstancedMesh` batch。 |
| `addBuilding(group)` / `removeBuilding(group)` | 添加或移除建筑，并同步更新 group 子节点。 |
| `getBuildingAt(index)` / `getBuildingById(id)` / `getBuildings()` | 按下标、`Building.ID` 或整体列表获取建筑。 |
| `floorMeshes` | 聚合返回所有建筑内的 `FloorMesh[]`。 |
| `setFacilityLayer(layer)` | 设置跨楼层设备渲染层；传入 `null` 会移除已有 layer。 |
| `getDefaultFacilityLayer()` | 返回当前默认 `FacilityInstancedLayer`；没有可实例化 Facilities 时返回 `null`。 |
| `getFacilityById(id)` | 在整次语义解析结果中按 `Facility.ID` 或显式别名查找设备。 |
| `showAllFacilities()` / `hideAllFacilities()` | 显示或隐藏整次语义解析结果中的全部设备。 |
| `showAllFloors()` | 显示所有建筑的所有楼层。 |
| `planishFloors(kind?)` / `unplanishFloors(kind?)` | 压扁或恢复所有楼层；默认覆盖所有合并语义实例和 Facilities，并按包围盒贴到楼层平面附近，同时添加稳定的轻微 Y 偏移以减少共面闪烁；传入 `kind` 时只作用于该语义类型，`kind` 可包含 `Facilities`。 |

### `BuildingGroup`

`BuildingGroup` 继承自 `BaseGroup`，用于组织一个建筑下的所有楼层、电梯井和通风井。设备语义对象不会直接挂到 `BuildingGroup`；能 instancing 的设备会渲染到 `SemanticGroup.facilityLayer`，同时在对应 `FloorMesh` 下保留可检索、可控制的轻量引用。

建筑内设备 ID 到楼层的关系由内部 `Map` 索引维护，并通过楼层设备索引版本自动同步。

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `floorMeshes` | 当前建筑内的 `FloorMesh[]`。 |
| `elevatorMeshes` | 当前建筑内由 `Elevators` 语义生成的 `ExtrudeMesh[]`。 |
| `ventMeshes` | 当前建筑内由 `Vents` 语义生成的 `ExtrudeMesh[]`。 |
| `addFloor(floor)` | 添加楼层，并同步加入 group。 |
| `removeFloor(floor)` | 移除楼层，并同步从 group 移除。 |
| `getFloorAt(index)` | 按楼层下标获取 `FloorMesh`。 |
| `getFloorById(id)` | 按楼层语义 ID 获取 `FloorMesh`，内部使用 `Map` 索引。 |
| `addElevator(elevator)` / `removeElevator(elevator)` | 添加或移除电梯井，并同步更新 group。 |
| `getElevatorAt(index)` | 按下标获取电梯井 `ExtrudeMesh`。 |
| `getElevatorById(id)` | 按电梯井语义 ID 获取电梯井，内部使用 `Map` 索引。 |
| `addVent(vent)` / `removeVent(vent)` | 添加或移除通风井，并同步更新 group。 |
| `getVentAt(index)` | 按下标获取通风井 `ExtrudeMesh`。 |
| `getVentById(id)` | 按通风井语义 ID 获取通风井，内部使用 `Map` 索引。 |
| `isolateFloor(floor \| index)` | 只显示指定楼层，隐藏其他楼层；也兼容传入楼层数组。 |
| `isolateFloors(floors \| indexes)` | 只显示指定多个楼层，隐藏其他楼层。 |
| `showAllFloors()` | 显示全部楼层。 |
| `getFacilityById(id)` | 在当前建筑的楼层中按 `Facility.ID` 或 `FloorMesh.addFacility()` 显式传入的别名查找设备。 |
| `showAllFacilities()` | 显示整栋建筑内全部楼层设备。 |
| `hideAllFacilities()` | 隐藏整栋建筑内全部楼层设备。 |
| `planishFloors(kind?)` | 压扁所有楼层；默认覆盖所有合并语义实例和 Facilities，并按包围盒贴到楼层平面附近，同时添加稳定的轻微 Y 偏移以减少共面闪烁；传入 `kind` 时只压扁该语义类型，`kind` 可包含 `Facilities`。 |
| `unplanishFloors(kind?)` | 恢复所有楼层；默认恢复所有合并语义实例和 Facilities；传入 `kind` 时只恢复该语义类型，`kind` 可包含 `Facilities`。 |

### 电梯井和通风井

`SemanticLoader` 会读取 `semantic_model.json` 中建筑引用的 `Elevators` 和 `Vents`。每个井道使用 `Polygon.shape` 作为底面轮廓，`Elevation` 作为底面高度，`TotalHeight` 作为拉伸高度，并以 building 级 `ExtrudeMesh` 加入 `BuildingGroup`。

```typescript
const elevator = building.getElevatorAt(0);
const elevatorById = building.getElevatorById('ELEVATOR_001');
const vent = building.getVentAt(0);
const ventById = building.getVentById('VENT_001');

if (elevator) {
  await viewer.controls.flyToObject(elevator);
}

if (vent) {
  building.removeVent(vent);
}
```

电梯井和通风井会在渲染阶段沿法线轻微内收，减少与墙体共面时的闪面。`Spaces` 实例也会使用稳定的 per-instance 渲染偏移抖动，缓解完全重叠空间之间的 z-fighting；这些偏移只影响渲染顶点，不改变语义 ID、实例矩阵和包围体查询的使用方式。

### 设施设备

`SemanticLoader` 会从 `Stories[].Facilities` 读取设备索引，再从顶层 `Facilities` 数组取得 `Facility` 数据，并使用 `Facility.ID` 在解密后的 `tree_models` 中查找同 ID 节点。只有带 `renderType: '3D'` 和模型 `path` 的节点会被加载。

`tree_models.matrix` 是相对父节点的本地矩阵；`SemanticLoader` 会沿树父级累乘得到设备世界矩阵，再转换为对应 `FloorMesh` 的局部矩阵。因此楼层移动、隔离或显示隐藏时设备会跟随楼层一起工作。即使某个 story 只有 `Facilities`、没有墙柱空间等合并几何，也会创建一个空的 `FloorMesh` 作为设备容器。

Facilities 会在单次 `SemanticParser` 解析范围内按模型 URL 自动分组。静态模型会被转换为 scene-level `InstancedMesh` batch 并挂到 `SemanticGroup.facilityLayer`；每个设备仍会在所属 `FloorMesh` 下保留一个 `FacilityInstanceObject` 实例引用，用于 `getFacilityById()`、显隐、颜色、透明度、包围盒和 `viewer.objectManager` 检索。设备 batch 使用普通 `Group` 包裹，以保证每帧渲染前都能执行相机实例裁剪和 hide/show 后的实例同步；带动画、骨骼、morph target 或缺少合法 `geometry.groups` 的多材质 Mesh 会自动回退为普通 `Model`，但普通 `Model` 也会挂到同一个 `FacilityInstanceObject` wrapper 下，对外 API 不变。

`facilityLayer` 使用 `FacilityInstancedLayer`，并继承自 `BaseGroup`。当 `facilityLayer.visible = false` 时，layer 会阻止内部 `InstancedMesh` 继续参与射线检测，避免隐藏的批处理设备仍被点击命中；单个设备的显隐应先通过 `getFacilityById()` 或 `viewer.objectManager.getById()` 取回实例，再调用 `setInstanceVisible()` 控制。

Instanced 设备通过 per-instance opacity attribute 控制单个设备透明度。batch 材质默认沿用源材质的透明状态；只有源材质本身透明，或当前参与渲染的设备存在 `opacity < 1` 时，材质才会切到 `transparent: true`，`setInstanceOpacity(1)` 可在没有其他半透明实例时回到不透明渲染队列。`resetInstanceColor()` 只清除颜色 override，不影响透明度 override。`FacilityInstancedLayer` 会在设备显隐、颜色、透明度、floor planish/unplanish 改变设备 transform，或相机矩阵、投影矩阵、viewport 高度变化时，在下一次渲染前同步实例矩阵、颜色、透明度和当前 active instance count。普通 fallback `Model` 的颜色和透明度控制由 wrapper 委托到 `MaterialEffects`，单材质和多材质 Mesh 都会应用 override。

加载成功后，`BuildingGroup`、`FloorMesh` 仍会保留自身的 `semanticId`、`semanticKind` 和 `semanticName`；设备引用则使用通用 `InstanceObject` 身份字段 `instanceId`、`instanceKind` 和 `instanceName`。`SemanticGroup.getBuildingById()`、`BuildingGroup.getFloorById()`、`BuildingGroup.getFacilityById()`、`FloorMesh.getFacilityById()` 以及 `ModelInstancedLayer` 的查询、删除和 raycast remap 都使用这些对象字段或显式传入的别名，不再从 `userData.id`、`userData.semanticId`、`userData.facilityId` 里扫描 ID。`userData` 只保留原始业务元数据，例如 `facilityId`、`spaces`、`twinsIdentifier`、`storyId`、`floorIndex`、`instanced` 和 `modelUrl`。

```typescript
const floor = building.getFloorAt(0);

const facility = floor.getFacilityById('FACILITY_001') ?? viewer.objectManager.getById('FACILITY_001');
const facilityEntity = floor.getSemanticById('FACILITY_001');

if (facility && facilityEntity?.type === 'object') {
  await viewer.controls.flyToObject(facility, {
    viewpoint: 'rightFrontTop',
    padding: 0.2,
  });
}

const facilities = floor.getSemanticsByKind('Facilities');

facility
  .setInstanceColor('#ff5533')
  .setInstanceOpacity(0.45)
  .resetInstanceColor()
  .setInstanceVisible(true);
```

对 Facilities 推荐直接使用 `controls.flyToObject()` 飞向设备。无论设备实际渲染来自 `InstancedMesh` 还是 fallback `Model`，`FacilityInstanceObject` 都会暴露本地 `boundingBox` / `boundingSphere`，让 Three.js 的 `Box3.setFromObject()` 能计算完整世界包围盒。

### `FacilityInstanceObject`

`FacilityInstanceObject` 继承自统一的 `InstanceObject`，表示一个设备实例引用。它会挂在所属 `FloorMesh` 下，用于 ID 检索、事件派发、显隐、颜色、透明度和包围盒查询；如果设备可以 instancing，真正几何由 `SemanticGroup.facilityLayer` 里的 `InstancedMesh` 统一渲染；如果不能 instancing，fallback `Model` 会作为它的子对象挂载。

完整的继承字段、方法、颜色模式、包围盒和 dirty 同步语义见 [核心 `InstanceObject` API](./api-objects.md#instanceobject)。

通常不需要手动创建 `FacilityInstanceObject`。`SemanticParser` 在解析 `Facilities` 时会自动创建它，并通过 `setInstanceIdentity()` 写入 `instanceId`、`instanceKind` 和 `instanceName`。`userData.instanced` 表示当前设备是否由 `InstancedMesh` 渲染；fallback 时仍然保留同一套实例 API。

加载完成后，设备可以按 `Facility.ID` 从全局 `objectManager` 取回，并直接传给 `viewer.controls.flyToObject()`。如果需要自己合并多个对象或调整包围盒，也可以继续调用 `getInstanceBoundingBox(box, true)`。

```typescript
import { FacilityInstanceObject } from 'u-space/plugins/u-manager';

const facility = viewer.objectManager.getById<FacilityInstanceObject>('FACILITY_001');

if (facility?.isFacilityInstanceObject) {
  await viewer.controls.flyToObject(facility, {
    viewpoint: 'rightFrontTop',
    padding: 0.2,
    enableTransition: true,
  });
}
```

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `isFacilityInstanceObject` | 类型标记，值为 `true`。 |
| `type` | Three.js 对象类型名，值为 `'FacilityInstanceObject'`。 |
| `instanceId` | 实例独立 ID，设备默认等于 `Facility.ID`；layer 的按 ID 查询、删除和 raycast remap 都使用该字段。 |
| `instanceKind` | 实例类型，设备为 `'Facilities'`。 |
| `instanceName` | 实例显示名称，设备默认使用 `Facility.Name`。 |
| loader-created bounds | `ModelInstancedLayer.addInstances()` / `reserveBatch()` 会在建 batch 时通过继承的 `setInstanceBounds()` 写入模板包围盒，因此实例可直接传给 `viewer.controls.flyToObject()`。 |
| `userData.instanced` | `true` 表示由 `FacilityInstancedLayer` 批量渲染；`false` 表示 fallback `Model` 挂在当前对象下。 |

```typescript
import { FacilityInstanceObject } from 'u-space/plugins/u-manager';

const facility = floor.getFacilityById('FACILITY_001');

if (facility instanceof FacilityInstanceObject) {
  facility
    .setInstanceColor('#29ccff')
    .setInstanceOpacity(0.6)
    .setInstanceVisible(true);

  await viewer.controls.flyToObject(facility, {
    viewpoint: 'rightFrontTop',
    padding: 0.2,
  });
}
```

### `FacilityInstancedLayer`

`FacilityInstancedLayer` 继承自 `ModelInstancedLayer`，是 scene-level Facilities 的批量渲染层。`SemanticGroup.facilityLayer` 默认就是该类型；它会按模型 URL 把同一模板的静态设备合并为一个或多个 `InstancedMesh`，并用普通 `Group` 包裹每个模型 URL batch，保证设备显隐、颜色、透明度和相机实例裁剪能在每帧渲染前同步，同时保留每个楼层下的 `FacilityInstanceObject` 引用用于业务 API 和事件冒泡。

使用 `SemanticLoader` 时通常不需要直接调用 `addInstances()`；只有自定义语义解析、运行时新增设备或自建设备 batch 时才需要手动创建 layer，然后通过 `semanticGroup.setFacilityLayer(layer)` 挂回语义场景。

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `isFacilityInstancedLayer` | 类型标记，值为 `true`。 |
| `type` | Three.js 对象类型名，值为 `'FacilityInstancedLayer'`。 |
| `name` | 构造函数默认设为 `'Facilities'`。 |
| `getInstanceCulling()` | 返回当前实例裁剪配置：`enabled`、`frustum`、`minScreenRadius`。 |
| `setInstanceCulling(options)` | 设置实例裁剪配置，并返回自身。默认 `enabled: false`、`frustum: true`、`minScreenRadius: 0`；默认不按相机裁剪设备，避免相机距离影响设备显隐。需要性能压缩时可设置 `enabled: true` 开启视锥裁剪，或再把 `minScreenRadius` 设为大于 0 的值开启屏幕尺寸裁剪。 |
| `getInstances()` | 返回当前 layer 内全部实例对象数组。 |
| `getInstanceById(id)` | 根据实例对象的 `instanceId` 快速获取实例；找不到时返回 `undefined`。 |
| `hasInstance(id)` | 判断当前 layer 是否包含对应 `instanceId` 的实例。 |
| `reserveBatch(url, template, capacity)` | 为某个模型 URL 预分配 batch 容量。适合接下来会多次运行时新增实例的场景，成功返回 `true`，模板不支持 instancing 时返回 `false`。 |
| `addInstance(url, template, instance)` | 向某个模型 URL 的 batch 新增一个实例。成功返回 `true`，失败时调用方应回退为普通模型渲染。 |
| `addInstances(url, template, instances)` | 向某个模型 URL 的 batch 批量新增实例。推荐运行时新增多个实例时优先使用，避免连续单个新增造成重复扩容检查。 |
| `removeInstance(instanceOrId)` | 删除一个实例，可传实例对象或完整 `instanceId`。删除成功返回 `true`。 |
| `removeInstances(instancesOrIds)` | 删除一个或多个实例。可传单个实例对象、单个字符串 `instanceId`，或混合实例对象和 ID 的 iterable；返回实际删除数量。 |
| `removeBatch(url)` | 删除某个模型 URL 的完整 batch，释放对应内部 `InstancedMesh` 和 cloned materials。 |
| `clearBatches()` | 清空当前 layer 的所有模型 batch，并返回自身。 |

`addInstances(url, template, instances)` 会按模型 URL 复用或创建 batch，从 `template` 收集可 instancing 的可见 `Mesh`，克隆源材质，并为每个 batch 建立 instance matrix、颜色和透明度 attribute。没有动画、没有骨骼、没有 morph target，且每个多材质 `Mesh` 都有合法 `geometry.groups` 的模板会进入 instancing；不满足条件、模板没有可用 Mesh、`instances` 为空，或待加入实例的 `instanceId` 与当前 layer 内已有实例重复时会返回 `false`。

动态新增时，layer 会维护每个 URL batch 的 `capacity`。容量足够时，新增实例只会写入逻辑数组、注册 dirty callback 并标记下一帧同步；容量不足时才按 2 倍增长策略重建该 URL 下的内部 `InstancedMesh` 和 attribute buffer。运行时需要连续新增大量实例时，推荐先调用 `reserveBatch(url, template, expectedCount)`，再调用 `addInstances()`。删除实例不会立即收缩 GPU buffer；只有 batch 为空、调用 `removeBatch()` 或 `clearBatches()` 时才释放内部渲染资源。

外部代码不需要直接写内部 `InstancedMesh.instanceMatrix`。要移动、旋转或缩放实例时，直接修改对应 `InstanceObject` 的 transform 即可；transform 的世界矩阵发生变化时会自动标记实例 dirty，`ModelInstancedLayer` 会在下一次渲染前把世界矩阵同步到内部 instance matrix 并设置 `instanceMatrix.needsUpdate = true`。因此即使底层使用 WebGPU storage instanced buffer，公开更新方式仍然是面向实例对象：

```typescript
const instance = layer.getInstanceById('FACILITY_001');

if (instance) {
  instance.position.set(10, 0, 5);
  viewer.render();
}
```

如果应用为静态大场景关闭了根 Scene 的 `matrixWorldAutoUpdate`，修改单个实例后还需显式调用 `instance.updateWorldMatrix(true, false)` 和 `viewer.invalidate()`；修改包含实例的父 Group 时调用 `group.updateWorldMatrix(true, true)`。这会执行 `InstanceObject` 的原生方法覆盖并触发 dirty callback，不需要直接调用 layer 内部同步方法。

删除 API 支持单个字符串 ID，不需要为了删除一个实例额外包一层数组。下面两个调用等价，都会按完整 ID 删除实例：

```typescript
layer.removeInstance('FACILITY_001');
layer.removeInstances('FACILITY_001');
```

渲染前，layer 会同步每个 `FacilityInstanceObject` 的世界矩阵、颜色和透明度，并只在数据变化时上传对应 buffer。默认情况下，相机移动不会改变设备 active instance 集合；显式调用 `setInstanceCulling({ enabled: true })` 后，layer 才会按当前渲染相机逐实例压缩 `InstancedMesh.count`，视锥外设备不会进入本帧实例 buffer；只有同时设置 `minScreenRadius > 0` 时，屏幕半径小于该阈值的设备才会被跳过。相机矩阵、投影矩阵或 viewport 高度未变化时不会重复压缩；设备显隐、透明度为 0、颜色/透明度变化或 `planish('Facilities')` / `unplanish('Facilities')` 改变 transform 时会标记 batch dirty。全部设备不可渲染时，内部 `InstancedMesh.visible` 会被关闭；只有被当前相机裁掉时，mesh 会保留可见并把 `count` 设为 0，以便相机移动后自动恢复。射线检测命中 batch 内实例时，`hit.object` 会被改写为对应的 `FacilityInstanceObject`，并从实例对象字段附加 `instanceObject`、`instanceObjectId`、`instanceKind`、`instanceName` 和 `instanceIndex`，事件随后会按楼层引用对象继续冒泡。

```typescript
import { FacilityInstancedLayer, FacilityInstanceObject } from 'u-space/plugins/u-manager';

const layer = new FacilityInstancedLayer();
const refs = facilityItems.map(() => new FacilityInstanceObject());

layer.reserveBatch('/models/camera.glb', templateObject, refs.length + 100);

if (layer.addInstances('/models/camera.glb', templateObject, refs)) {
  semanticGroup.setFacilityLayer(layer);
}

// 运行时按完整字符串 ID 删除一个实例；返回实际删除数量。
layer.removeInstances('FACILITY_001');
```

如果只想整体隐藏或禁用 scene-level batch，可以设置 `semanticGroup.facilityLayer.visible = false`；如果只想控制单个设备，推荐从 `floor.getFacilityById(id)` 或 `viewer.objectManager.getById(id)` 取回 `FacilityInstanceObject` 后调用 `setInstanceVisible()` / `setInstanceOpacity()`。

### `ModelInstancedLayer`

`ModelInstancedLayer` 是核心 `src/batches` 模块导出的公共类，也是 `SceneInstancedLayer` 和 `FacilityInstancedLayer` 的共享实现。它负责模板 mesh 收集、材质克隆、`InstancedMesh` 创建、运行时实例新增/删除、capacity 扩容、instance matrix/color/opacity buffer 同步、raycast hit remap、fallback 判定和可选相机实例裁剪。模板 mesh 会保持独立，避免跨楼壳合并后破坏透明排序和局部包围体；射线通过实例包围体后，三角形数量较高的静态模板几何会按需建立并复用 `three-mesh-bvh`，小几何或不受支持的几何布局自动保留 Three.js 默认路径。普通静态模型可以由调用方对明确的热点 root 使用 `enableMeshBVHRaycast(root)`，避免对整个大型场景自动建树造成首击卡顿。layer 内部只依赖 `InstanceObject` 的 `instanceId`、`instanceKind`、`instanceName` 独立字段，不再扫描 `instance.userData` 的 `facilityId`、`id` 或 `sid`。`getInstanceById()` / `removeInstances(id)` 会按需刷新 id 索引；新增实例时如果发现重复 `instanceId` 会拒绝加入，避免同一个 layer 内出现不确定查询结果。通用 API 见 [Batches API](./api-batches#modelinstancedlayer)。

### `FloorMesh`

`FloorMesh` 继承自 `BaseMesh`，表示一个楼层。它不是 `BatchedMesh`，而是把同一楼层内的语义对象合并为一份几何，并通过 TSL + `DataTexture` 在 GPU 侧按 `semanticIndex` 控制颜色、透明度、显示隐藏和实例矩阵。楼层语义网格会按实例透明度拆分为不透明和透明两个材质通道；墙、柱、门等默认不透明实例会进入 opaque pipeline，`Spaces`、`Windows` 或通过 `setOpacityAt()` 改成半透明的实例会进入 transparent pipeline。Facilities 通过楼层下的引用对象进行检索和控制；实际渲染可能来自 `SemanticGroup.facilityLayer` 的 `InstancedMesh` batch，也可能是 fallback 的普通 `Model`。

这种结构的目标是让每个楼层通常只占一个主渲染 draw call，同时仍保留实例级控制能力。`xxxAt` 方法全部作用于单个语义实例；没有 `At` 后缀的方法作用于整个楼层 mesh。

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `semanticCount` / `instanceCount` | 当前楼层语义实例数量。 |
| `semanticInstances` | `Map<string, number>`，从语义对象 ID 映射到实例下标。 |
| `wallsInstances` / `columnsInstances` / `spacesInstances` / `doorsInstances` / `windowsInstances` / `staircasesInstances` | 按语义类型维护的 ID 到实例下标映射。 |
| `semanticEntities` | `Map<string, FloorSemanticEntity>`，统一索引楼层内合并几何语义和 `Facilities` 对象语义。 |
| `facilityObjectsById` | `Map<string, FacilityInstanceObject>`，按 `Facility.ID` 或显式别名查找设备引用。 |
| `facilityObjects` | 当前楼层挂载的设备引用数组。 |
| `getSemanticIndexById(id)` | 根据语义 ID 获取实例下标。 |
| `getSemanticById(id)` | 根据语义 ID 或设备别名获取统一语义实体。 |
| `getSemantics()` | 获取楼层内全部语义实体，包含合并几何和设备对象。 |
| `getSemanticsByKind(kind)` | 按语义类型过滤实体；`kind` 可以是 `Walls`、`Columns`、`Spaces`、`Doors`、`Windows`、`Staircases` 或 `Facilities`。 |
| `getSemanticIdAt(index)` | 获取实例语义 ID。 |
| `getSemanticKindAt(index)` | 获取合并几何实例类型：`'Walls' \| 'Columns' \| 'Spaces' \| 'Doors' \| 'Windows' \| 'Staircases'`。 |
| `getSemanticNameAt(index)` | 获取实例名称；当前 `Spaces` 会从语义数据的 `Name` 写入，其他类型可能为 `undefined`。 |
| `getSemanticObjectAt(index)` | 获取合并几何实例对应的 `FloorSemanticInstanceObject` 轻量对象。 |
| `showAllFacilities()` | 显示当前楼层的全部设备对象。 |
| `hideAllFacilities()` | 隐藏当前楼层的全部设备对象。 |
| `addFacility(object, ids?)` | 把设备对象挂到楼层，并按对象 `instanceId` 和显式 `ids` 建立检索别名。不会从 `object.userData` 自动扫描 ID。 |
| `getFacilityById(id)` | 根据 `Facility.ID` 或别名获取设备对象。 |
| `getFacilityAt(index)` | 按楼层设备数组下标获取设备对象。 |
| `getFacilities()` | 获取当前楼层全部设备对象。 |
| `removeFacility(facility)` | 按设备对象或 ID 从楼层移除设备，并同步清理检索索引。 |
| `setColorAt(index, color)` / `getColorAt(index, target?)` | 修改或读取实例颜色，支持 `Color`、`Vector3`、`Vector4`。 |
| `setOpacityAt(index, opacity)` / `getOpacityAt(index)` | 修改或读取实例透明度。 |
| `setVisibleAt(index, visible)` / `getVisibleAt(index)` | 修改或读取实例显隐状态。 |
| `setMatrixAt(index, matrix)` / `getMatrixAt(index, target?)` | 设置或读取实例附加矩阵。 |
| `setScaleAt(index, scale, y?, z?)` | 修改实例缩放，支持 `number` 或 `Vector3`。 |
| `setScaleYAt(index, y)` | 只修改实例 Y 轴缩放，保留 X/Z 缩放和已有位移、旋转。 |
| `getFinalMatrixAt(index, target?)` | 读取实例最终矩阵，包含语义对象原始基点矩阵。 |
| `getBoundingBoxAt(index, target?, world?)` | 获取实例包围盒；`world = true` 时包含 `FloorMesh.matrixWorld`。 |
| `getBoundingSphereAt(index, target?, world?)` | 获取实例包围球；`world = true` 时包含 `FloorMesh.matrixWorld`。 |
| `computeBoundingBox()` / `computeBoundingSphere()` | 计算整个楼层的包围体。 |
| `planish(kind?)` / `unplanish(kind?)` | 压扁或恢复楼层内所有合并语义实例和 Facilities；压平时按各自包围盒贴到楼层平面附近，并加入稳定的轻微 Y 偏移；传入 `kind` 时只作用于该语义类型，`kind` 可包含 `Facilities`。 |

### 飞向语义实例

点击、射线检测命中 `FloorMesh` 里的语义几何或设备时，`intersect` 上会附加语义字段：`semanticIndex`、`semanticId`、`semanticKind` 和 `semanticObject`。普通楼层语义几何的事件目标是 `FloorSemanticInstanceObject`；instanced Facilities 的事件目标是楼层下的 `FacilityInstanceObject` 引用，事件会继续冒泡到所属 `FloorMesh`、`BuildingGroup` 和 `SemanticGroup`。因此楼层监听器中应使用 `event.currentTarget` 操作楼层，用 `intersect.semanticObject` 或 `event.target` 取得具体语义实例。

```typescript
import { Box3 } from 'three';

const box = new Box3();

floor.addEventListener('click', async ({ event }) => {
  const { currentTarget, intersect } = event;
  if (!intersect) return;

  if (intersect.semanticKind === 'Facilities') {
    const facility = intersect.semanticObject ?? event.target;

    await viewer.controls.flyToObject(facility, {
      viewpoint: 'rightFrontTop',
      padding: 0.2,
    });
    return;
  }

  if (intersect.semanticObject) {
    await viewer.controls.flyToObject(intersect.semanticObject, {
      viewpoint: 'rightFrontTop',
      padding: 0.2,
    });
    return;
  }

  const semanticBox = currentTarget.getBoundingBoxAt(intersect.semanticIndex, box, true);

  if (semanticBox) {
    await viewer.controls.flyToBox(semanticBox, {
      viewpoint: 'rightFrontTop',
      padding: 0.2,
    });
  }
});
```

如果要根据 ID 操作对象，先通过 `getSemanticIndexById(id)` 转为实例下标。

```typescript
const index = floor.getSemanticIndexById('SPACE_001');

if (index !== undefined) {
  floor.setVisibleAt(index, false);
  viewer.render();
}
```

### 楼层压扁

`planish()` / `unplanish()` 默认作用于当前楼层的全部合并语义实例，包含墙、柱、空间、门窗、楼梯等语义几何，也包含楼层下的 Facilities 引用；传入 `kind` 时只处理该类型，例如 `floor.planish('Walls')` 或 `floor.planish('Facilities')`。所有被压平的实例都会按自身包围盒把压平后的薄片贴到楼层平面 `FloorMesh.pivot.y` 附近，而不是围绕实例自身基点压缩；压平时还会按语义类型和实例 ID 加入稳定的轻微 Y 偏移，减少门、墙或其他重叠实例之间的共面闪烁。`unplanish()` 会恢复合并几何实例的原始矩阵，`unplanish('Facilities')` 会恢复设备原始 `position.y` 和 `scale.y`。instanced Facilities 会通过楼层下的 `FacilityInstanceObject` transform 同步到 `SemanticGroup.facilityLayer`。

如果只想调整单个合并几何实例，可以使用 `setScaleAt()` 或 `setScaleYAt()`，它们会保留该语义对象自己的基点矩阵，因此压扁墙、柱、房间时不会被压到世界原点。

## `SceneLoader`

从服务端场景包加载完整场景树（模型、组、基础形状），并自动将所有已加载对象按 `id` 和 `sid` 注册到 `viewer.objectManager`。
默认会尝试读取同一路径下的 `db/semantic_model.json`，如果场景树节点的 `id` 已存在于语义文件中，则跳过该节点，方便和 `SemanticLoader` 同时加载而不重复创建建筑或设备。
对语义去重后仍保留的 3D 节点，`SceneLoader` 默认会按模型 `path` 自动合并重复静态模型：同一 `path` 的多个实例只加载一次模板，并由 `SceneInstancedLayer` 通过共享的 `ModelInstancedLayer` 批量渲染。原场景树中仍会保留每个 `id` / `sid` 对应的 `SceneInstanceObject` 逻辑对象，用于业务检索、显隐、颜色、透明度、包围盒和事件派发。

如果场景主要由大量普通 Mesh 组成，并且比默认的同 URL instancing 更重视 draw call 和可渲染节点数量，可以显式调用 `setEditableBatching()`。该模式会把兼容 Mesh 的变换烘焙到顶点，再按材质和 Geometry 布局合并为少量 `SceneEditableBatchLayer` Mesh；逻辑 `SceneInstanceObject` 仍留在原场景树中。它是静态 Geometry batching，不是 `InstancedMesh`、WebGPU indirect draw 或 compute rasterizer。

两种 layer 是 `SceneLoader` 的可替换主渲染策略，不存在继承关系：默认模式把 `SceneInstancedLayer` 设为 `SceneGroup.sceneLayer`；启用 editable batching 且至少生成一个 merged batch 时改为 `SceneEditableBatchLayer`。后者只接收兼容的不透明静态 Mesh；不兼容模板或子 Mesh 会继续尝试放入一个名为 `SceneEditableBatchFallback` 的独立 `SceneInstancedLayer`，重复 fallback 无法 instancing 时才退回普通 `Model`。因此 `SceneGroup.sceneLayer` / `getDefaultSceneLayer()` 只暴露主 `SceneEditableBatchLayer`，fallback `SceneInstancedLayer` 是同一 `SceneGroup` 下的辅助渲染层，不会替换主 layer；如果没有生成任何 merged Mesh，主 layer 会被移除并返回 `null`，即使同级 fallback 仍然存在。

模板包含动画、骨骼、morph target、非法多材质 groups 或没有可实例化网格时，会自动回退为普通 `Model` 渲染；fallback `Model` 会挂在同一个 `SceneInstanceObject` 下，因此调用侧不需要额外配置。

```typescript
import { SceneLoader } from 'u-space/plugins/u-manager';

const sceneLoader = new SceneLoader(viewer);
sceneLoader.setPath('./scenes/my-scene');
sceneLoader.setKey('YOUR_LICENSE_KEY'); // 官方授权场景必需
sceneLoader.setEditableBatching({
  maxVerticesPerBatch: 1_500_000,
  maxIndicesPerBatch: 4_500_000,
  freezeAnimations: true,
});
const group = await sceneLoader.loadAsync(); // SceneGroup
viewer.scene.add(group);

const sceneLayer = group.getDefaultSceneLayer();
```

**方法：**

| 方法                | 说明                                                                 |
| :------------------ | :------------------------------------------------------------------- |
| `setKey(key)`       | 设置授权场景的 RSA 解密密钥。                                        |
| `setEditableBatching(options?)` | 开启可编辑静态 Geometry batching。传 `true` 使用默认选项，传 `false` 关闭；默认关闭。 |
| `loadAsync()`       | 加载并解析场景，返回去除语义重复节点后的 `SceneGroup`。              |
| `clearCache()`      | 从 `objectManager` 中移除此加载器注册的所有 ID。                     |
| `dispose()`         | 清除缓存并释放水印叠加层。                                           |

### 可编辑静态合批：`setEditableBatching()`

`setEditableBatching(options)` 是面向大型、主要静态但仍需按对象操作的场景的 opt-in 路径。开启后，`SceneLoader` 会把所有保留的 3D 节点创建为 `SceneInstanceObject`，不再只处理重复 `path`；每个唯一模型 URL 仍只加载一次模板。

```typescript
import {
  SceneLoader,
  type SceneEditableBatchOptions,
  type SceneEditableBatchStats,
} from 'u-space/plugins/u-manager';

const options: SceneEditableBatchOptions = {
  maxVerticesPerBatch: 1_500_000,
  maxIndicesPerBatch: 4_500_000,
  freezeAnimations: true,
};

const sceneLoader = new SceneLoader(viewer).setEditableBatching(options);
const sceneGroup = await sceneLoader.loadAsync();
viewer.scene.add(sceneGroup);

const stats = sceneGroup.userData.editableBatch as SceneEditableBatchStats | undefined;
console.table(stats);
```

**配置：**

| 字段 | 默认值 | 说明 |
| :--- | :--- | :--- |
| `maxVerticesPerBatch` | `1_500_000` | 单个 merged Geometry 的最大顶点数；超过后开始新 chunk。 |
| `maxIndicesPerBatch` | `4_500_000` | 单个 merged Geometry 的最大索引数；超过后开始新 chunk。 |
| `freezeAnimations` | `false` | `false` 时含 animation clip 或 mixer 的模板整体 fallback；`true` 时烘焙当前姿态，后续播放动画前必须先 materialize。SkinnedMesh 和 morph target 始终 fallback。 |

#### 合并规则

每个候选 Mesh 会生成稳定的 material key 和 geometry-layout key。只有以下状态完全兼容的 Mesh 才进入同一组：

- 材质类型、颜色/粗糙度等序列化参数，以及贴图源和 sampler/transform 配置一致。
- Geometry 的 indexed/non-indexed 类型、index 数组类型、attribute 名称、`itemSize`、`normalized`、数组类型和 `gpuType` 一致。Geometry 内容和顶点数量可以不同。
- `castShadow`、`receiveShadow` 和 `renderOrder` 一致。

合并前会为每个源 Mesh 克隆 Geometry，并应用以下矩阵：

```text
inverse(batchLayer.matrixWorld) × instance.matrixWorld × meshLocalMatrix
```

随后为全部顶点写入 `batchObjectIndex` attribute，再通过 `mergeGeometries()` 生成 merged Mesh。批次达到顶点或索引上限时自动切分，因此每个兼容组可能生成多个 draw call。

该方案会把重复模板的顶点展开到 merged Geometry，通常以更高的 GPU 顶点内存换取更少的 draw calls 和可渲染 Mesh 遍历；重复率极高、几何较大的同模板对象仍可能更适合原生 `SceneInstancedLayer`。

#### 对象级状态与 materialize

合批后，每个逻辑对象在 Float `DataTexture` 中占一个 RGBA texel，batch NodeMaterial 通过 TSL `colorNode` 和独立布尔 `maskNode` 读取 `batchObjectIndex` 对应的颜色与显隐状态；不透明 batch 保留源材质原有的 opacity 路径。因此以下操作只会把对象标记为 dirty，并立即更新 CPU 状态纹理数据，GPU 在下一帧上传，不会重新合并 Geometry：

- `setInstanceVisible()` 和父级显隐变化。
- `setInstanceColor()` / `resetInstanceColor()`。
- 不引入半透明的 `setInstanceHighlight()` / `clearInstanceHighlight()`。

如果对象的世界 transform 与烘焙矩阵不同，或者有效 opacity 低于 `0.999`，该对象会自动 `materialize()`：原始完整模板被 clone 为普通 `Model`，其中每个材质为当前实例独立克隆，然后挂到当前 `SceneInstanceObject` 下，同时 merged batch 中的旧副本被 mask 掉并从 BVH 命中过滤。根 Scene 使用 `matrixWorldAutoUpdate = false` 时，transform 修改后必须调用该对象的 `updateWorldMatrix(true, false)`，或在父 Group 上调用 `updateWorldMatrix(true, true)`，才能提交新世界矩阵并立即触发 dirty/materialize 检测。该同步发生在内部 batch 视锥裁剪前，从屏幕外移动到屏幕内也不会丢失。materialize 是单向操作；对象不会在恢复原矩阵或不透明度后自动重新进入 batch，需要重新加载场景。也可以提前显式调用：

```typescript
const object = viewer.objectManager.getById<SceneInstanceObject>('SCENE_NODE_ID');
const model = object?.materialize();

if (model) {
  object.position.x += 2;
  object.setInstanceOpacity(0.5);
  await viewer.render();
}
```

#### Fallback 与透明材质

透明材质、`opacity < 0.999`、SkinnedMesh、morph target、多材质数组、自定义 NodeMaterial / `onBeforeCompile`、非标准 Geometry 布局、负行列式变换和未冻结动画不会进入 merged batch。负 determinant 会按实例拆分；同模板的正 determinant 实例仍可合并，而负缩放实例直接使用普通 `Model`，不会继续进入同样不支持负缩放的 `SceneInstancedLayer`。混合模板会只合并兼容的不透明 Mesh，并生成一个隐藏这些已合并 Mesh 的 fallback 模板来保留透明或其他不支持的子 Mesh；build 期间已经因初始 opacity materialize 的实例不会再次加入透明 fallback。其余同 URL fallback 有多个实例时仍由 `SceneInstancedLayer` 渲染，单例则使用普通 `Model`。

这条边界用于保留透明排序、蒙皮/变形和自定义 shader 行为。不要通过强制改成不透明材质来提高合批率。

#### Raycast 与统计

merged Mesh 首次实际拾取时会按需建立 `three-mesh-bvh`。命中结果通过 `faceIndex → vertexIndex → batchObjectIndex` 映射回原始 `SceneInstanceObject`，因此 `intersect.object` 仍是业务逻辑对象；已经 materialize 的对象不会在旧烘焙位置继续产生命中。

`SceneEditableBatchStats` 写入 `sceneGroup.userData.editableBatch`：

| 字段 | 说明 |
| :--- | :--- |
| `instances` | 进入 editable batch 状态表的逻辑对象数。 |
| `sourceMeshes` | 合并前的兼容源 Mesh 数。 |
| `batches` | 最终 merged Mesh 数。 |
| `drawCallsSaved` | `sourceMeshes - batches` 的理论 draw-call 减少量，不包含多 pass 放大。 |
| `vertices` / `indices` | 烘焙后的总顶点数和索引数，用于评估显存代价。 |
| `unsupportedInstances` | 需要完整或部分 fallback 的逻辑对象数。 |
| `unsupportedByReason` | 按 `transparent`、`animation`、`skinned`、`morph`、`material-array`、`geometry-layout`、`negative-scale` 等原因统计。 |

WebGPU 首次创建大量 batch material pipeline 时可能出现短暂的 shader 编译开销。如果产品不希望首个可见帧展示未完成的材质，可在需求渲染模式下暂时停止相机控制产生的 render invalidation，完成 `compileAsync()` 后再提交首帧；`examples/test_umanager2.html` 展示了这一流程。

### `SceneGroup`

`SceneGroup` 继承自 `BaseGroup`，是 `SceneLoader.loadAsync()` 的返回根组。它保留 `tree_models.json` 解析后的原始父子层级，并把重复静态模型的默认合批渲染层挂在 `sceneLayer`，方便外部直接访问，而不需要遍历 `children`。

| 属性 / 方法 | 说明 |
| :---------- | :--- |
| `isSceneGroup` | 固定为 `true`，用于判断对象是否为 `SceneLoader` 根组。 |
| `sceneLayer` | 默认 path instancing 下为 `SceneInstancedLayer`；启用 editable batching 后为 `SceneEditableBatchLayer`；没有可合批模型时为 `null`。 |
| `setSceneLayer(layer)` | 设置 `SceneInstancedLayer` 或 `SceneEditableBatchLayer`；传入 `null` 会移除已有 layer。 |
| `getDefaultSceneLayer()` | 返回当前默认场景渲染层；没有可合批模型时返回 `null`。 |

### `SceneInstanceObject`

`SceneInstanceObject` 继承自统一的 `InstanceObject`，表示一个由 `SceneInstancedLayer`、`SceneEditableBatchLayer` 或 fallback 普通模型渲染的场景模型引用。它会保留在原场景树层级中，并注册到 `viewer.objectManager`，因此 `getById()`、`show()` / `hide()`、`isolate()`、`showAll()`、`setOpacity()` 和 `getBoundingBox()` 可以继续按单个模型 ID 使用。

完整的继承字段、方法、颜色模式、包围盒和 dirty 同步语义见 [核心 `InstanceObject` API](./api-objects.md#instanceobject)。

`SceneInstanceObject` 本身只增加场景模型标记：`isSceneInstanceObject = true`、`type = 'SceneInstanceObject'`、`instanceKind = 'SceneInstances'`。加载器会把场景树节点的 `id` 和 `sid` 都注册到 `viewer.objectManager`，因此同一个对象可以通过任一 ID 取回。对重复模型的 instanced 路径，`instanceId` 默认等于场景树节点 `id`，`instanceName` 默认等于节点名称；`userData.instanced = true`，并保留 `modelPath` 和 `modelUrl` 等原始加载元数据。如果模板不支持 instancing，会退回普通 `Model`，但仍挂在同一个 `SceneInstanceObject` 下，对外 API 不变。

**识别字段：**

| 字段 | 说明 |
| :--- | :--- |
| `isSceneInstanceObject` | 固定为 `true`，用于判断对象是否来自 `SceneLoader` 的场景实例。 |
| `type` | 固定为 `SceneInstanceObject`。 |
| `instanceId` | 实例独立 ID，默认等于场景树节点 `id`；layer 的按 ID 查询、删除和 raycast remap 都使用该字段。 |
| `instanceKind` | 实例类型，场景实例为 `'SceneInstances'`。 |
| `instanceName` | 实例显示名称，默认等于场景树节点名称。 |
| `userData.id` / `userData.sid` | 原始场景树节点 ID；两个 ID 都会注册到 `viewer.objectManager`。 |
| `userData.instanced` | 加载时选择 batch 路径的元数据。editable batch 对象 materialize 后该字段不作为实时状态；使用 `getInstanceRenderObject()` 是否非空判断当前是否已有普通渲染对象。 |
| `userData.modelPath` / `userData.modelUrl` | instanced 场景实例对应的模型相对路径和完整 URL。 |

**方法：**

| 方法 | 说明 |
| :--- | :--- |
| 继承的实例 API | 完整定义见 [核心 `InstanceObject` API](./api-objects.md#instanceobject)；包含身份、bounds、fallback render object、显隐、颜色、透明度、高亮和 dirty callback。 |
| `materialize()` | 如果对象由 `SceneEditableBatchLayer` 渲染，clone 原始完整模板并将当前对象从 merged/fallback batch 中移出；默认 path instancing 或普通 fallback 下返回已有 render object 或 `null`。 |

`MaterialEffects.highlightColor()` / `removeHighlightColor()` 会识别 `SceneInstanceObject` 并调用 `setInstanceHighlight()` / `clearInstanceHighlight()`，不会直接修改共享 batch 材质。`ObjectManager.setOpacity()`、`ObjectManager.hide()`、`ObjectManager.show()` 也会通过统一 API 作用到单个实例。

加载完成后可以按场景树节点 `id` 从 `objectManager` 取回 `SceneInstanceObject`，再直接传给 `viewer.controls.flyToObject()`。`SceneInstanceObject` 内部带有本地 `boundingBox` 缓存能力，因此不需要加载真实 Mesh 副本也能计算飞行包围盒。

```typescript
import { SceneInstanceObject } from 'u-space/plugins/u-manager';

const object = viewer.objectManager.getById<SceneInstanceObject>('SCENE_NODE_ID');

if (object?.isSceneInstanceObject) {
  await viewer.controls.flyToObject(object, {
    viewpoint: 'current',
    padding: 0.2,
    enableTransition: true,
  });
}
```

颜色、透明度和显隐可以直接链式调用：

```typescript
object
  ?.setInstanceColor('#29ccff')
  .setInstanceOpacity(0.55)
  .setInstanceVisible(true);

object?.setInstanceHighlight('#ffb703', 0.75, true);
object?.clearInstanceHighlight();
object?.resetInstanceColor();
```

如果需要自己控制包围盒，也可以取世界包围盒后调用 `flyToBox()`：

```typescript
import { Box3 } from 'three/webgpu';

const box = object.getInstanceBoundingBox(new Box3(), true);
await viewer.controls.flyToBox(box, { viewpoint: 'frontTop', padding: 0.2 });
```

射线检测命中批处理实例时，`intersect.object` 会被改写为对应的 `SceneInstanceObject`，并附加 `instanceObject`、`instanceObjectId`、`instanceKind: 'SceneInstances'`、`instanceName` 和 `instanceIndex`。Three.js 原生的 `intersect.instanceId` 仍保留为内部 `InstancedMesh` 的数字实例下标，事件会按逻辑对象所在的原场景树继续冒泡。

### `SceneInstancedLayer`

`SceneInstancedLayer` 是 `SceneLoader` 内部使用的批量渲染层，默认 `name = 'SceneInstances'`。它继承自 `ModelInstancedLayer`，每个重复模型 URL 会生成一个 batch，batch 内按模板的可实例化 Mesh 创建 `InstancedMesh`。渲染前只在实例矩阵、显隐、颜色或透明度变化时同步 instance buffer；普通相机移动不会重新上传所有实例数据。

### `SceneEditableBatchLayer`

`SceneEditableBatchLayer` 继承自核心 `EditableGeometryBatchLayer<SceneInstanceObject>`，后者继承自 `BaseGroup`。它是 `setEditableBatching()` 创建的 scene-specific 静态 Geometry 合并层，默认 `name = 'SceneEditableBatches'`；通用的材质/Geometry 分组、变换烘焙、chunk 切分、TSL 状态纹理、materialize、lazy BVH 和资源释放都由核心层实现。内部未导出的 `EditableGeometryBatchMesh` 继承自 `BaseMesh`；隐藏整个 layer 或单个 batch Mesh 时，`ignoreInvisibleWhenRaycast` 会在进入自定义 BVH raycast 前停止命中，阴影属性仍由对应源 Mesh 的 `castShadow` / `receiveShadow` 覆盖。

它不继承 `SceneInstancedLayer`。开启 editable batching 且生成 merged Mesh 时，`SceneEditableBatchLayer` 是 `SceneGroup.sceneLayer` 的主层；不兼容的重复 fallback 由同级、名称为 `SceneEditableBatchFallback` 的 `SceneInstancedLayer` 辅助渲染，单例或仍不支持 instancing 的对象使用挂在 `SceneInstanceObject` 下的普通 `Model`。没有 merged Mesh 时主 layer 为 `null`，辅助 fallback 不会被 `getDefaultSceneLayer()` 返回。

通常由 `SceneLoader` 管理，不需要业务代码直接调用 scene adapter 的 `addTemplate()` / `build()`，也不需要自行处理 materialize 后的 fallback 移除。公开的 `stats` 和 `options` 可用于诊断；materialize 通知使用继承的类型化 `materialize` 事件，释放独立创建的 layer 时应调用 `dispose()`。如果要在其他插件或应用中直接复用同一机制，应使用核心 [EditableGeometryBatchLayer](./api-batches#editablegeometrybatchlayer) 的 `addSource()` API。

## `TopologiesLoader` / `TopologyParser`

加载拓扑图数据并将其转换为 `Topology` 对象。

```typescript
import { TopologiesLoader } from 'u-space/plugins/u-manager';

const loader = new TopologiesLoader();
loader.setPath('./scenes/my-scene');
const topologies = await loader.loadAsync(); // Topology[]
viewer.scene.add(...topologies);
```

## `VisionsLoader` / `VisionsParser`

加载命名相机视点，并将相机飞行到指定视点。

```typescript
import { VisionsLoader, VisionsParser } from 'u-space/plugins/u-manager';

const visionsLoader = new VisionsLoader();
visionsLoader.setPath('./scenes/my-scene');
const visionsData = await visionsLoader.loadAsync();
// visionsData 是 Record<string, IVisions[]>

const visionsParser = new VisionsParser(viewer);

// 飞行到指定视点
await visionsParser.flyTo(visionsData['HOME'][0]);

// 飞行到主（默认）视点
await visionsParser.flyToPrimary(visionsData['HOME']);
```

**`IVisions` 字段：**

| 字段       | 类型          | 说明                            |
| :--------- | :------------ | :------------------------------ |
| `camera`   | `'P' \| 'O'` | 相机类型：透视或正交。          |
| `position` | `IVector3`    | 相机世界位置。                  |
| `target`   | `IVector3`    | 相机朝向目标点。                |
| `zoom`     | `number`      | 相机缩放级别。                  |
| `primary`  | `boolean`     | 是否为默认视点。                |

## `AnimationsLoader` / `AnimationsParser`

加载关键帧动画数据，并通过 `tweenAnimation` 驱动 `Object3D` 的变换。

```typescript
import { AnimationsLoader, AnimationsParser } from 'u-space/plugins/u-manager';

const animLoader = new AnimationsLoader();
animLoader.setPath('./scenes/my-scene');
const animationsData = await animLoader.loadAsync(); // IAnimations[]

// 查找特定对象的动画
const data = animationsData.find((a) => a.modelId === myModel.userData.id);

const parser = new AnimationsParser(viewer, myModel);
parser.initTransform(); // 保存初始位置/旋转/缩放

await parser.play(data.keyframes); // 播放动画序列

// 中途停止
parser.stop();

// 重置到初始变换
parser.reset();
```

## `PropertiesLoader`

加载与模型关联的结构化属性元数据（如 BIM 属性）。

```typescript
import { PropertiesLoader } from 'u-space/plugins/u-manager';

const propsLoader = new PropertiesLoader();
propsLoader.setPath('./scenes/my-scene');
const properties = await propsLoader.loadAsync(); // IProperties[]

// 查找模型的属性
const modelProps = properties.filter(p => p.modelId === myModel.userData.id);
```

**`IProperties` 字段：**

| 字段      | 类型             | 说明                     |
| :-------- | :--------------- | :----------------------- |
| `modelId` | `string`         | 关联模型的 ID。          |
| `group`   | `string`         | 属性分组/类别名称。      |
| `key`     | `string`         | 属性键名。               |
| `value`   | `string \| null` | 属性值。                 |
| `label`   | `string \| null` | 属性的显示标签。         |
