UNPKG

prophetk-3d-viewer

Version:

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

430 lines (400 loc) • 16 kB
const THREE = require('../vendor/three-r128.js'); const DragPlaneGrid = require('./DragPlaneGrid.js'); const traverseModel = require('./modelTraversal.js'); const DRAG_THRESHOLD = 8; const TOUCH_RADIUS = 12; const MIN_PLANE_ANGLE = Math.sin(10 * Math.PI / 180); const BLOCKED_MESSAGE = '当前角度不便移动,请稍微俯视后再拖动。'; function visible(object) { for (let node = object; node; node = node.parent) { if (node.visible === false) return false; } return true; } function disposeHelper(helper) { if (!helper) return; if (helper.parent) helper.parent.remove(helper); helper.geometry.dispose(); const materials = Array.isArray(helper.material) ? helper.material : [helper.material]; materials.forEach((material) => material.dispose()); } // One owner per gesture: UI, model, camera, or pinch. Selection is deliberately // independent from ModelRenderer's current decal target. class ModelInteraction { constructor(renderer, handler) { this.renderer = renderer; this.handler = typeof handler === 'function' ? handler : null; this.models = new Map(); this.selectedId = null; this.gesture = null; this.raycaster = new THREE.Raycaster(); this.selectionBox = null; this.grid = null; this.pendingDrag = null; this.helperScene = new THREE.Scene(); this.configure(); } configure(options = {}) { this.showDragGrid = options.showDragGrid !== false; this.showBounds = options.showBounds !== false; } register(id, object, isMovable, clickEvent) { if (this.models.has(id)) this.remove(id); object.updateWorldMatrix(true, true); const inverse = object.matrixWorld.clone().invert(); const bounds = new THREE.Box3(); const meshes = []; traverseModel(object, (mesh) => { if (!mesh.isMesh || !mesh.geometry) return; if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); if (!mesh.geometry.boundingSphere) mesh.geometry.computeBoundingSphere(); const matrix = new THREE.Matrix4().multiplyMatrices(inverse, mesh.matrixWorld); bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(matrix)); meshes.push(mesh); }); this.models.set(id, { id, object, isMovable: isMovable === true, bounds, meshes, clickEvent: typeof clickEvent === 'string' ? clickEvent.trim() : '' }); } remove(id, reason = 'modelremoved') { if (this.gesture && [this.gesture.entry, this.gesture.clickEntry].some((entry) => entry && entry.id === id)) { this.cancel(reason); } if (this.selectedId === id) this.select(null, reason); this.models.delete(id); } reset(reason = 'scenechange') { this.cancel(reason); this.select(null, reason); this.models.clear(); } clearSelection() { this.cancel('selectioncleared'); this.select(null, 'selectioncleared'); } select(id, reason = 'pointer') { if (id === this.selectedId) return; const previousModelId = this.selectedId; this.selectedId = id; disposeHelper(this.selectionBox); this.selectionBox = null; if (id !== null && this.showBounds) { this.selectionBox = new THREE.Box3Helper(new THREE.Box3(), 0x80cfff); this.selectionBox.material.transparent = true; this.selectionBox.material.opacity = 0.75; this.selectionBox.material.depthWrite = false; this.selectionBox.material.depthTest = true; this.selectionBox.renderOrder = 1; this.selectionBox.raycast = () => {}; this.helperScene.add(this.selectionBox); this.updateHelper(); } this.renderer._requestRender(); this.emit({ type: 'selectionchange', modelId: id, previousModelId, reason }); } worldBounds(entry) { entry.object.updateWorldMatrix(true, true); return entry.bounds.clone().applyMatrix4(entry.object.matrixWorld); } updateHelper() { const entry = this.models.get(this.selectedId); if (!entry || !this.selectionBox) return; this.selectionBox.visible = visible(entry.object); this.selectionBox.box.copy(this.worldBounds(entry)); this.selectionBox.material.opacity = this.gesture && this.gesture.dragStarted ? 1 : 0.75; } ray(point) { const r = this.renderer; r._camera.updateMatrixWorld(true); this.raycaster.near = r._camera.near; this.raycaster.far = r._camera.far; this.raycaster.setFromCamera(new THREE.Vector2( point.x / r._width * 2 - 1, 1 - point.y / r._height * 2 ), r._camera); return this.raycaster.ray; } pick(point, touch, allModels = false) { const candidates = Array.from(this.models.values()).filter((entry) => { if (!visible(entry.object)) return false; for (let node = entry.object; node; node = node.parent) { if (node === this.renderer._sceneRoot) return true; } return false; }); const bounds = candidates.map((entry) => this.worldBounds(entry)); const sample = (p) => { const ray = this.ray(p); let nearest = null; candidates.forEach((entry, i) => { if (!ray.intersectsBox(bounds[i])) return; const hits = this.raycaster.intersectObjects(entry.meshes.filter(visible), false); const hit = hits.find((item) => { const material = Array.isArray(item.object.material) ? item.object.material[item.face.materialIndex] : item.object.material; return material && material.visible !== false; }); if (hit && (!nearest || hit.distance < nearest.distance)) { nearest = { entry, point: hit.point.clone(), distance: hit.distance }; } }); return nearest && (allModels || nearest.entry.isMovable) ? nearest : null; }; const direct = sample(point); if (direct || !touch) return direct; const r = this.renderer; const rect = r._getCanvasRect(); let nearest = null; for (let i = 0; i < 8; i += 1) { const angle = i * Math.PI / 4; const p = { x: point.x + Math.cos(angle) * TOUCH_RADIUS * r._width / rect.width, y: point.y + Math.sin(angle) * TOUCH_RADIUS * r._height / rect.height, }; if (p.x < 0 || p.y < 0 || p.x > r._width || p.y > r._height) continue; const hit = sample(p); if (hit && (!nearest || hit.distance < nearest.distance)) nearest = hit; } return nearest; } start(e) { const r = this.renderer; const touches = e.touches || []; if (touches.length >= 2) { if (this.gesture && this.gesture.kind === 'waiting') return; if (this.gesture && this.gesture.kind === 'pinch') return; const previous = this.gesture; this.finishDrag('multitouch'); if (this.gesture !== previous || r._disposed) return; this.releaseUi(); this.gesture = { kind: 'pinch', distance: r._pinchDist(touches) }; return; } if (touches.length !== 1 || this.gesture) return; const point = r._eventPointToCanvas(e); if (!point) return; const ui = r._hitTestUi(point.x, point.y); if (ui) { this.gesture = { kind: 'ui', ui }; if (ui.userData.type === 'slider') r._updateSliderFromPoint(ui, point, true); else r._setUiPressed(ui, true); return; } const hit = this.pick(point, e.pointerType !== 'mouse'); const clickHit = this.pick(point, e.pointerType !== 'mouse', true); const g = { kind: hit ? 'model' : 'camera', origin: point, last: point, moved: false, cameraMode: r.getCameraControlMode(), touch: e.pointerType !== 'mouse', clickEntry: clickHit && clickHit.entry.clickEvent ? clickHit.entry : null }; this.gesture = g; if (hit) { g.entry = hit.entry; g.startPosition = hit.entry.object.position.clone(); g.startWorld = hit.entry.object.getWorldPosition(new THREE.Vector3()); g.plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -hit.point.y); // Use the central finger ray even when touch tolerance selected a nearby // mesh; this preserves the original finger-to-object offset. g.anchor = this.planePoint(point, g.plane); this.select(hit.entry.id); } } move(e) { const r = this.renderer; const touches = e.touches || []; const g = this.gesture; if (!g || g.kind === 'waiting') return; if (touches.length >= 2 && g.kind !== 'pinch') { this.start(e); return; } if (g.kind === 'pinch') { if (touches.length < 2) { g.kind = 'waiting'; return; } const distance = r._pinchDist(touches); r._zoomCameraBy((g.distance - distance) * 0.03); g.distance = distance; return; } const point = r._eventPointToCanvas(e); if (!point) return; if (g.kind === 'ui') { if (g.ui.userData.type === 'slider') r._updateSliderFromPoint(g.ui, point, true); else r._setUiPressed(g.ui, r._hitTestUi(point.x, point.y) === g.ui); return; } if (g.blocked) return; const dx = point.cssX - g.origin.cssX; const dy = point.cssY - g.origin.cssY; if (!g.moved && Math.hypot(dx, dy) <= DRAG_THRESHOLD) return; g.moved = true; if (g.kind === 'camera') { const deltaX = point.cssX - g.last.cssX; const deltaY = point.cssY - g.last.cssY; if (g.cameraMode === 'pan') { r._panCamera(deltaX, deltaY); } else { r._orbit.theta -= deltaX * 0.008; r._orbit.phi = Math.max(0.05, Math.min(Math.PI - 0.05, r._orbit.phi - deltaY * 0.008)); r._requestRender(); } g.last = point; return; } const intersection = this.planePoint(point, g.plane); if (!g.anchor || !intersection) { g.blocked = true; this.pendingDrag = null; disposeHelper(this.grid); this.grid = null; r._requestRender(); this.emitDrag('dragblocked', g, 'low-angle', BLOCKED_MESSAGE); return; } if (!g.dragStarted) { g.dragStarted = true; this.showGrid(g.entry); this.emitDrag('dragstart', g); if (this.gesture !== g) return; // Host callbacks may remove the model. } const position = g.startWorld.clone().add(intersection.sub(g.anchor)); position.y = g.startWorld.y; const object = g.entry.object; if (object.parent) { object.parent.updateWorldMatrix(true, false); if (Math.abs(object.parent.matrixWorld.determinant()) < 1e-12) return; object.parent.worldToLocal(position); } if (!position.equals(object.position)) { object.position.copy(position); this.pendingDrag = g; r._requestRender(); } } planePoint(point, plane) { const ray = this.ray(point); if (Math.abs(ray.direction.y) < MIN_PLANE_ANGLE) return null; return ray.intersectPlane(plane, new THREE.Vector3()); } end(e) { const g = this.gesture; if (!g) return; const remaining = (e.touches || []).length; if (g.kind === 'pinch' || g.kind === 'waiting') { this.gesture = remaining ? { kind: 'waiting' } : null; return; } if (remaining) return; const r = this.renderer; const point = r._eventPointToCanvas(e, true); // Consume a final release position without inventing a second owner. if (point && (g.kind === 'model' || g.kind === 'camera')) { this.move({ touches: e.changedTouches || [], pointerType: e.pointerType }); if (this.gesture !== g) return; } this.gesture = null; if (g.kind === 'ui') { r._setUiPressed(g.ui, false); if (g.ui.userData.type === 'slider') { if (point) r._updateSliderFromPoint(g.ui, point, true); r._emitUiEvent(g.ui, point); } else if (point && r._hitTestUi(point.x, point.y) === g.ui) { r._emitUiEvent(g.ui, point); } } else if (g.kind === 'camera' && !g.moved) { this.select(null); } else { this.finishDrag('release', g); } // Selection callbacks may remove models or replace the scene. Revalidate // the entry after those callbacks, with this gesture already released. const entry = g.clickEntry; if (entry && !g.moved && point && !r._disposed && this.models.get(entry.id) === entry && !r._hitTestUi(point.x, point.y)) { const hit = this.pick(point, g.touch, true); if (hit && hit.entry === entry) { this.emit({ type: 'click', modelId: entry.id, event: entry.clickEvent }); } } } cancel(reason = 'touchcancel') { const g = this.gesture; this.releaseUi(); this.gesture = null; this.pendingDrag = null; disposeHelper(this.grid); this.grid = null; if (g && g.dragStarted) { g.entry.object.position.copy(g.startPosition); this.renderer._requestRender(); this.emitDrag('dragcancel', g, reason); } } releaseUi() { const g = this.gesture; if (g && g.kind === 'ui') this.renderer._setUiPressed(g.ui, false); } finishDrag(reason, g = this.gesture) { this.pendingDrag = null; disposeHelper(this.grid); this.grid = null; if (g && g.dragStarted) { g.dragStarted = false; this.renderer._requestRender(); this.emitDrag('dragend', g, reason); } } showGrid(entry) { if (!this.showDragGrid) return; const box = this.worldBounds(entry); const size = box.getSize(new THREE.Vector3()); const r = this.renderer; const camera = r._camera; const viewHeight = 2 * r._orbit.radius * Math.tan(camera.fov * Math.PI / 360) / camera.zoom; // Choose a readable 1/2/5 world-unit interval once per drag. Small props // get the same scene reference as large models at the same camera distance. const desired = Math.max(viewHeight / 20, 0.0001); const magnitude = Math.pow(10, Math.floor(Math.log10(desired))); const fraction = desired / magnitude; const spacing = (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * magnitude; const renderer = r._renderer; const derivatives = renderer && ((renderer.capabilities && renderer.capabilities.isWebGL2) || (renderer.extensions && renderer.extensions.has('OES_standard_derivatives'))); this.grid = new DragPlaneGrid( box.min.y + Math.max(size.y, 0.01) * 0.001, spacing, derivatives ); this.grid.update(camera, r._height); this.helperScene.add(this.grid); } renderHelpers(renderer, camera) { if (!this.helperScene.children.length) return; // Preserve scene depth: bounds use normal occlusion while the grid ignores // depth. Neither helper writes depth; canvas UI is drawn afterward. const autoClear = renderer.autoClear; renderer.autoClear = false; try { renderer.render(this.helperScene, camera); } finally { renderer.autoClear = autoClear; } } beforeRender() { this.updateHelper(); if (this.grid) this.grid.update(this.renderer._camera, this.renderer._height); const g = this.pendingDrag; this.pendingDrag = null; if (g && this.gesture === g) this.emitDrag('drag', g); } blocksWheel() { return !!this.gesture; } onCameraModeChange() { // A mode switch must not reinterpret an in-progress finger movement. // Model/UI gestures and pinch zoom have the same meaning in both modes. if (this.gesture && this.gesture.kind === 'camera') this.gesture = { kind: 'waiting' }; } emitDrag(type, g, reason, message) { this.emit({ type, modelId: g.entry.id, startPosition: g.startPosition.toArray(), position: g.entry.object.position.toArray(), ...(reason ? { reason } : {}), ...(message ? { message } : {}) }); } emit(detail) { if (this.handler) this.handler(detail); } } module.exports = ModelInteraction;