UNPKG

prophetk-3d-viewer

Version:

A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.

378 lines (305 loc) • 17.8 kB
# 模型交互与相机控制 适用于 `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 导出以及两端输入适配。