# 模型交互与相机控制

适用于 `prophetk-3d-viewer` 0.4.0，微信小程序和 Web 共用同一套交互逻辑。

## 场景配置

```js
const sceneConfig = {
  environment: {
    url: '',
    showSkybox: false,
    showDragGrid: true,
    showBounds: true,
  },
  camera: {
    controlMode: 'orbit',
    position: [0, 1.5, 3],
    lookAt: [0, 0, 0],
    fov: 60,
  },
  models: [
    {
      id: 'accessory',
      url: 'demoRes/bucket.glb',
      isMovable: true,
      position: [0, 0, 0],
      rotation: [0, 0, 0],
      scale: [1, 1, 1],
    },
  ],
};
await viewer.loadScene(sceneConfig);
```

`viewer` 可以是微信页面通过 `this.selectComponent('#viewer')` 得到的组件、
Web 的 `createWebModelViewer()` 返回值，或核心 `ModelRenderer` 实例。

| 配置项 | 默认值 | 含义 |
| --- | --- | --- |
| `models[].isMovable` | `false` | 仅显式设置为 `true` 的模型支持选中和拖动；一个 GLB 整体作为一个对象 |
| `models[].invisible` | `false` | 不渲染模型及后代，但保留点击、选择和拖动逻辑；仍需分别配置 `clickEvent`、`isMovable` |
| `models[].clickEvent` | 无 | 非空事件名启用点击，与能否拖动无关 |
| `models[].parent` | `null` | 父模型 ID；位置、旋转、缩放相对父模型，父级未就绪时暂不显示 |
| `camera.controlMode` | `'orbit'` | 空白或不可移动模型上的拖动：`orbit` 为绕点旋转，`pan` 为 XZ 平移 |
| `environment.showDragGrid` | `true` | 拖动模型时显示水平参考网格 |
| `environment.showBounds` | `true` | 显示当前选中模型的包围框 |

显示开关只控制辅助图形，不影响选择与移动。每次 `loadScene()` 使用新场景的配置；
省略这些字段时恢复表中的默认值。配置对象不会被交互逻辑自动改写。

## 不可见模型的交互

模型配置支持 `invisible: true`，适用于不可见的点击区域。`loadScene()` 和
`loadModel()` 均支持，小程序和 Web 行为一致：

```js
await viewer.loadModel({
  id: 'hotspot',
  url: 'hotspot.glb',
  invisible: true,
  isMovable: false,
  clickEvent: 'openDetails',
});
```

此模型不绘制颜色、深度或阴影，仍按真实网格表面检测点击，事件格式不变。
`invisible` 不会自动开启点击或拖动：点击需要非空 `clickEvent`，选择和拖动需要
`isMovable: true`。包围框和拖动网格仍由 `showBounds`、`showDragGrid` 控制。

不可见模型仍参与拾取遮挡，前方不可见模型不会被穿透点击；普通的
`Object3D.visible = false` 仍会禁止交互。不可见父模型会让其后代也不渲染，后代仍按
各自配置响应交互；父模型尚未加载完成的子模型依然不显示、不参与交互。
省略 `invisible` 或设为 `false` 保持原有显示行为，模型资源仍需加载以用于表面拾取。

## 模型父子关系

`models[].parent` 可以指定另一个模型 ID，支持多级层级，配置顺序不限。
子模型的 `position`、`rotation`（角度制）和 `scale` 都是相对于父模型的 local TRS。
不配置 `parent` 或设置为 `null` 时，模型属于场景根节点。

```js
await viewer.loadScene({
  models: [
    // 子模型可以写在父模型前面。
    { id: 'accessory', parent: 'body', url: 'accessory.glb',
      position: [0, 1, 0], rotation: [0, 90, 0], scale: [0.4, 0.4, 0.4],
      isMovable: false, clickEvent: 'openDetails' },
    { id: 'body', url: 'body.glb', isMovable: true },
  ],
});

viewer.SetParent('accessory', null);   // 回到场景根节点
viewer.setParent('accessory', 'body'); // 两个接口名称等价
```

`SetParent(modelId, parentId)` 同步生效，返回 `void`。换父级或解除父级时保持
local TRS 数值不变，世界姿态可能改变。未知子模型 ID、自引用、循环引用和无效父级
参数会报错；子模型已提交加载但尚未完成时也可以换父级。微信组件尚未初始化时调用
会报 `renderer not ready`，此时应通过加载配置指定 `parent`。

**父模型未就绪时，子模型不显示、不参与交互，但可以后台加载资源。**
`loadModel()` 在自身资源就绪后完成，`loadScene()` 在本批资源及场景加载任务完成后
完成；两者均不等待尚未提交的外部父模型，因而可以这样顺序加载：

```js
await viewer.loadModel({ id: 'child', parent: 'parent', url: 'child.glb', normalize: false });
// 此时 child 已加载，但不可见。
await viewer.loadModel({ id: 'parent', url: 'parent.glb', normalize: false });
// parent 及其祖先就绪后，child 自动挂接显示。
```

父模型失败时对应加载 Promise reject，子模型继续等待；可以用相同 ID 重试失败的父模型。
其他已存在或正在加载的模型 ID 不允许重复。场景中的重复 ID 和循环关系会在清空旧场景前
拒绝。无关模型正常加载，父级缺失不会让子模型临时出现在场景原点。

父子模型仍独立响应点击和拖动，使用各自的 `clickEvent`、`isMovable`；点击事件不向
父模型冒泡。拖动父模型时后代一起移动，拖动子模型时修改其 local position，保持世界
Y 不变。父级变换不可逆时停止更新拖动位置。包围框与贴花只作用于模型自己的网格。
贴花尺寸计算也排除独立子模型；模型文件自带的 `modelId` 元数据不影响网格归属。
换父级会取消受影响的手势，拖动取消事件的 `reason` 为 `parentchange`。

删除父模型会级联删除全部后代，包括等待或正在加载的模型；即使父模型尚不存在，
`clearModel('parent')` 也能清理等待它的后代。返回数量为移除的模型记录数。
被删除、清空、重载或销毁取消的加载 Promise 会 reject，迟到的资源会释放而不会显示。

`clearModel('value')` 优先按模型 ID（包括尚未加载的父模型 ID）匹配，再尝试按 URL 匹配。
要严格按 URL 删除所有同源模型，使用 `clearModel({ url: 'asset.glb' })`；即使其他模型
恰好使用同名 ID，也不会误删。`clearModel({ id: 'body' })` 显式按 ID 删除。
所有形式均会级联删除匹配模型的后代。

`getModelTransforms()` 导出子模型时包含 `parent` 及 local TRS；根模型保持原有结构。
等待父级但已加载的子模型也会导出。默认演示中的 `box3` 是 `box1` 的不可拖动子模型，
可点击，并可通过页面按钮挂接或解除父级。

## 模型选择和移动

| 操作 | 结果 |
| --- | --- |
| 点击可移动模型 | 单选该模型，再点击另一个可移动模型可切换选择 |
| 按住可移动模型并移动 | 同一次手势完成选中和拖动，不需要长按等待 |
| 点击空白或不可移动模型 | 取消选择 |
| 拖动空白或不可移动模型 | 操作相机，保留已有选择 |
| 双指张开／收拢 | 拉近／拉远相机 |
| 操作画布按钮或滑块 | 优先响应控件 |

拖动超过 8 个 CSS 像素才开始移动，避免手指抖动。模型保持世界坐标 Y 不变，
只在 XZ 平面平移，旋转与缩放不变；保留原始抓取偏移，不会跳到手指中心。
手势开始后用途固定，移出模型轮廓仍继续拖动，从空白开始的操作也不会中途抓住模型。

拾取使用包围盒初筛和模型表面检测。手机触摸额外采样半径 12 CSS 像素的区域，
方便选中小配饰；其他模型仍参与遮挡判断，隐藏模型不参与拾取。

拖动中加入第二根手指，会保留当前位置并结束移动，再进入缩放。
双指变回单指后需全部松开，再开始新手势。系统取消触摸、窗口或页面失焦、
画布尺寸改变会取消本次拖动并恢复起始位置。

当拾取射线与移动平面夹角小于 10°，或无法得到前方有效交点时，停止更新位置，
保留最后有效位置，并提示稍微俯视后再拖动；不会自动改变相机角度。

### 辅助网格与 Bounds

水平网格覆盖相机可见的整个平面，高度固定在开始拖动时的模型底部。
参考线固定在世界坐标，不跟随模型移动；间距按视野尺度确定，远处密集线条淡出。
网格使用两个三角形绘制，支持 WebGL 1 无标准导数扩展时的兼容渲染。

- **网格不被模型遮挡**，用于展示完整的移动参考平面。
- **Bounds 保留深度遮挡**，位于模型后方的线段会被写入深度的几何体挡住。
- 两类辅助图形不写入深度，也不参与模型拾取；画布 UI 显示在它们上方。
- 松手后网格隐藏，选中框保留；取消选择、删除模型或重载场景会清理对应辅助图形。

```js
viewer.getSelectedModelId(); // 当前选中的模型 ID，未选中时返回 null
viewer.clearSelection();    // 取消选择；若正在拖动，同时撤销这一次移动
```

## 相机模式与缩放

```js
viewer.setCameraControlMode('pan');
viewer.setCameraControlMode('orbit');
const mode = viewer.getCameraControlMode();
```

`orbit` 围绕当前观察点旋转。`pan` 同时沿世界 XZ 平面平移相机和观察点，
保持方向、高度和观察距离不变。操作类似拖地图：向右拖动画面，相机向左移动；
上下拖动沿相机的水平方向调整远近位置。

模式切换不重置相机位置；切回旋转后绕平移后的观察点旋转。
若正在拖动相机，切换模式后需先松手；模型拖动、UI 和双指缩放不受模式切换影响。
不支持的模式值会抛出 `RangeError`。

微信组件允许在渲染器初始化前调用切换接口。调用 `loadScene()` 后、场景读取期间
再次设置的模式优先于该次读取到的配置；后续主动加载另一个场景则采用新场景的模式。
Web 或核心渲染器加载远程场景时，建议 `await loadScene()` 后再切换模式。

### Ctrl＋滚轮

Web 和微信开发者工具的 Android 模拟器均支持：鼠标放在画布内，
Ctrl＋滚轮向上拉近，向下拉远。普通滚轮也支持相同操作。
两个相机模式下均有效，与双指缩放共用相机距离限制：1–30 个世界单位。

缩放调整相机到观察点的距离，不缩放模型、不改变观察点与视角方向。
平移模式的固定高度约束只针对单指平移，缩放仍沿观察方向改变距离。
正在拖动、操作控件或使用触摸手势时，滚轮暂不生效，以防操作冲突。

Web 在画布内阻止浏览器页面缩放和滚动；微信组件通过 `catch:wheel` 转发
`event.detail`，因此不依赖模拟器是否提供 `ctrlKey` 字段。
需要自行对接输入时，核心提供：

```js
renderer.onWheel({ deltaY: -100, deltaMode: 0, ctrlKey: true });
// deltaY：负值拉近、正值拉远。
// deltaMode：0 像素（默认），1 行，2 页。
// 返回 boolean，表示本次是否实际改变了相机距离。
```

## 获取所有模型的 Transform JSON

```js
const json = viewer.getModelTransforms();
const transforms = JSON.parse(json);
```

返回值是 **JSON 字符串**，内容为数组。例如（以下为了便于阅读进行了排版）：

```json
[
  {
    "id": "accessory",
    "position": [1, 0, 2],
    "rotation": [0, 90, 0],
    "scale": [1, 1, 1]
  }
]
```

- 导出当前已经加载成功的全部模型，包含不可移动模型及等待父级的子模型。
- 子模型包含 `parent` 字段；场景根模型省略该字段。
- `position` 为相对父节点的位置，`rotation` 为 Euler XYZ **角度制**，`scale` 为三轴缩放。
- 旋转由当前四元数换算，数值可能与最初输入不同，但代表相同姿态。
- 不包含相机、灯光、辅助网格、Bounds、UI 或单个 GLB 内的子网格。
- 空场景、尚未初始化的微信组件返回字符串 `"[]"`。
- 拖动期间读取的是实时位置；如需保存已确认的位置，在 `dragend` 中读取。
- 方法只返回快照，不修改配置、不自动写入存储，也不自动上传。

这是 Transform 快照，不是完整场景 JSON。应使用稳定且唯一的模型 `id`，
将快照合并到原配置，保留 `url`、`isMovable` 等字段：

```js
const byId = new Map(JSON.parse(viewer.getModelTransforms()).map(item => [item.id, item]));
const savedScene = {
  ...sceneConfig,
  models: sceneConfig.models.map(item => {
    const transform = byId.get(item.id);
    if (!transform) return item;
    // 根模型快照省略 parent，需要显式清除原配置中的父级。
    return { ...item, ...transform, parent: transform.parent === undefined ? null : transform.parent };
  }),
};
// 之后可以 await viewer.loadScene(savedScene)。
```

通过 `loadModel()` 追加的模型也会被导出，应用需另行保存其模型来源配置。
TypeScript 中解析后的元素类型为包导出的 `ModelTransform`。

## 模型事件

### 配置点击事件

模型配置中的 `clickEvent` 是自定义事件名，与 `isMovable` 无关。
不可拖动模型也可以点击；省略或留空时不发送点击事件。
`loadScene()` 的 JSON 配置和 `loadModel()` 的模型参数均支持此字段：

```js
const model = {
  id: 'box1',
  url: 'demoRes/bucket.glb',
  isMovable: false,
  clickEvent: 'openDetails',
};
await viewer.loadModel(model);
```

微信小程序通过现有事件绑定调用页面中的自定义函数：

```xml
<model-viewer id="viewer" bind:modelevent="onViewerModelEvent" />
```

```js
Page({
  onViewerModelEvent(e) {
    const detail = e.detail;
    if (detail.type === 'click' && detail.event === 'openDetails') {
      this.openDetails(detail.modelId);
    }
  },
  openDetails(modelId) {
    wx.showToast({ title: '点击模型：' + modelId, icon: 'none' });
  },
});
```

Web 使用构造参数回调（也可监听画布的 `modelevent` DOM 事件）：

```js
const { createWebModelViewer } = require('prophetk-3d-viewer/web');
const viewer = createWebModelViewer({
  container: document.querySelector('#viewer'),
  onModelEvent(detail) {
    if (detail.type === 'click' && detail.event === 'openDetails') {
      openDetails(detail.modelId);
    }
  },
});
function openDetails(modelId) {
  console.log('点击模型：', modelId);
}
```

按下、松开命中同一模型且手势未超过 8 CSS 像素移动阈值时，松手发送一次
`{ type: 'click', modelId, event }`；重复点击已选中模型仍触发。
点击以整个模型为单位，沿用表面拾取与触摸容差，不穿透前方模型。
滑动、双指操作、取消手势、隐藏或移除模型不会触发点击，画布 UI 优先响应。
不可拖动模型上的滑动仍操作相机。事件名由页面显式分发，不会自动执行同名函数。

### 事件列表

微信组件使用 `bind:modelevent`；Web 与核心构造参数使用 `onModelEvent` 回调，
Web 画布也会发送 `modelevent` DOM 事件。

| `type` | 时机与数据 |
| --- | --- |
| `click` | 配置了 `clickEvent` 的模型被点击；包含 `modelId` 和 `event`（配置的事件名），不要求模型可拖动 |
| `selectionchange` | 选择发生变化；包含 `modelId`（可为 `null`）、`previousModelId`、`reason` |
| `dragstart` | 超过移动阈值且平面计算有效，开始实际拖动 |
| `drag` | 位置变化，最多每渲染帧通知一次 |
| `dragend` | 正常松手或转入双指操作，包含最终位置 |
| `dragcancel` | 本次拖动已撤销，位置恢复到起点 |
| `dragblocked` | 当前角度无法稳定移动；包含 `reason: 'low-angle'` 和提示 `message` |

移动相关事件均包含 `modelId`、`startPosition`、`position`；坐标与模型配置相同，
均为父级局部坐标。`dragend.reason` 为 `release` 或 `multitouch`。
取消原因包括 `touchcancel`、`blur`、`resize`、`selectioncleared`、`modelremoved`、
`scenechange`、`dispose`、`parentchange`。除 `drag` 外的事件即时发送；未开始实际移动就受阻时，
只发送 `dragblocked`，不会补发 `dragstart` 或 `dragend`。

```xml
<model-viewer id="viewer" bind:modelevent="onModelEvent" />
```

```js
Page({
  onModelEvent(e) {
    if (e.detail.type === 'dragend') {
      const json = this.selectComponent('#viewer').getModelTransforms();
      wx.setStorageSync('model-transforms', json);
    }
  },
});
```

## 本地资源、升级与边界

微信端支持 `demoRes/bucket.glb` 或 `./demoRes/bucket.glb`，相对于小程序根目录，
直接通过文件系统读取，不进行下载与远程缓存。Web 的相对地址按浏览器页面 URL 解析。
远程地址保留现有下载与缓存行为，微信端仍需配置合法域名。

仓库默认场景使用 `demoRes/plane.glb` 和 `demoRes/bucket.glb`。这些演示资源不随 npm
发布；接入项目应使用自己的模型和场景配置，或自行复制对应文件。
升级后在微信开发者工具中重新“构建 npm”并编译项目，确保新增的核心模块一并加载。

本版本支持静态 GLB 整体交互，不包含子节点编辑、碰撞、吸附、移动范围限制、
升降、旋转/缩放编辑或撤销历史。选择模型不改变贴花目标，投影贴花维持世界空间行为。

运行 `npm run check` 可检查包入口、点击和拖动、模型层级、异步加载取消、级联清理、
按 URL 删除、元数据兼容、贴花尺寸隔离、Transform 导出以及两端输入适配。
