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
JavaScript
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;