prophetk-3d-viewer
Version:
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
343 lines (302 loc) • 11.9 kB
JavaScript
const ModelRenderer = require('../../src/core/ModelRenderer.js');
const WechatPlatformAdapter = require('../../src/adapters/wechat/WechatPlatformAdapter.js');
const DEFAULT_SCENE_CONFIG = require('../../src/core/defaultSceneConfig.js');
function appendNoCacheParam(url) {
const sep = url.indexOf('?') === -1 ? '?' : '&';
return url + sep + '_t=' + Date.now();
}
Component({
properties: {
bgColor: { type: String, value: '' },
canvasId: { type: String, value: 'model-viewer-canvas' },
renderMode: { type: String, value: 'on-demand' },
},
data: {
loading: false,
error: '',
},
lifetimes: {
ready() {
setTimeout(() => this._initRenderer(), 300);
},
detached() {
this._inputEpoch = (this._inputEpoch || 0) + 1;
this._touchQueue = null;
this._detached = true;
if (this._renderer) this._renderer.dispose();
this._renderer = null;
},
},
pageLifetimes: {
hide() {
this.onTouchCancel({}, 'blur');
},
},
methods: {
loadModel(url, options = {}) {
if (!this._renderer) {
return new Promise((resolve, reject) => {
this._pendingLoad = { url, options, resolve, reject };
});
}
return this._doLoad(url, options);
},
loadScene(source) {
// A new scene supplies its own default. Later API calls take precedence,
// including while initialization or a remote scene read is pending.
this._cameraModeOverride = null;
if (!this._renderer) {
return new Promise((resolve, reject) => {
this._pendingScene = { source, resolve, reject };
});
}
return this._doLoadScene(source);
},
switchModel(url, options = {}) {
if (!this._renderer) return this.loadModel(url, options);
return this._renderer.switchModel(url, options);
},
clearModel(target) {
if (!this._renderer) return 0;
return this._renderer.clearModel(target);
},
ClearModel(target) {
return this.clearModel(target);
},
clearAllModels() {
if (!this._renderer) return 0;
return this._renderer.clearAllModels();
},
ClearAllModels() {
return this.clearAllModels();
},
setParent(modelId, parentId) {
if (!this._renderer) throw new Error('renderer not ready');
this._renderer.setParent(modelId, parentId);
},
SetParent(modelId, parentId) {
this.setParent(modelId, parentId);
},
getSelectedModelId() {
return this._renderer ? this._renderer.getSelectedModelId() : null;
},
getModelTransforms() {
return this._renderer ? this._renderer.getModelTransforms() : '[]';
},
clearSelection() {
if (this._renderer) this._renderer.clearSelection();
},
getCameraControlMode() {
if (this._cameraModeOverride) return this._cameraModeOverride;
return this._renderer ? this._renderer.getCameraControlMode() : 'orbit';
},
setCameraControlMode(mode) {
if (mode !== 'orbit' && mode !== 'pan') {
throw new RangeError('camera.controlMode must be "orbit" or "pan"');
}
this._cameraModeOverride = mode;
if (this._renderer) this._renderer.setCameraControlMode(mode);
},
applyDecal(url, options = {}) {
if (!this._renderer) return Promise.reject(new Error('renderer not ready'));
return this._renderer.applyDecal(url, options);
},
updateDecal(angle, height, scaleX = 1.0, scaleY = 1.0) {
if (this._renderer) this._renderer.updateDecal(angle, height, scaleX, scaleY);
},
updateProjectorDecal(options = {}) {
if (this._renderer) this._renderer.updateProjectorDecal(options);
},
removeDecal() {
if (this._renderer) this._renderer.removeDecal();
},
setUiLabelText(id, text) {
if (!this._renderer) return false;
return this._renderer.setUiLabelText(id, text);
},
onTouchStart(e) {
this._dispatchTouch('onTouchStart', e);
},
onTouchMove(e) {
this._dispatchTouch('onTouchMove', e);
},
onTouchEnd(e) {
this._dispatchTouch('onTouchEnd', e);
},
onWheel(e) {
// DevTools dispatches wheel deltas in detail (without DOM ctrlKey).
// The canvas consumes both ordinary and Ctrl-modified wheel gestures.
if (!this._renderer || this._touchQueue) return false;
return this._renderer.onWheel(e.detail || e);
},
onTouchCancel(e, reason = 'touchcancel') {
this._inputEpoch = (this._inputEpoch || 0) + 1;
this._touchQueue = null;
this._touchActive = false;
if (this._renderer) this._renderer.onTouchCancel(e, reason);
},
_dispatchTouch(method, e) {
if (!this._renderer) return;
const snapshot = {
touches: Array.from(e.touches || [], (t) => ({ ...t })),
changedTouches: Array.from(e.changedTouches || [], (t) => ({ ...t })),
pointerType: 'touch',
};
const first = method === 'onTouchStart' && !this._touchActive;
this._touchActive = snapshot.touches.length > 0;
if (this._touchQueue) {
this._touchQueue.push({ method, event: snapshot });
} else if (first) {
// Refresh the canvas rectangle after page scrolling. Preserve input order
// while the Mini Program's asynchronous selector query is pending.
this._touchQueue = [{ method, event: snapshot }];
const epoch = this._inputEpoch || 0;
wx.createSelectorQuery().in(this).select('#' + this.data.canvasId)
.boundingClientRect().exec((res) => {
if (!this._renderer || epoch !== (this._inputEpoch || 0)) return;
const rect = res && res[0];
if (!rect || !rect.width || !rect.height) {
this.onTouchCancel({});
return;
}
const renderer = this._renderer;
if (rect.width !== renderer._width || rect.height !== renderer._height) {
renderer.resize(rect.width, rect.height, rect);
} else {
renderer._canvasRect = rect;
}
const queue = this._touchQueue || [];
this._touchQueue = null;
queue.forEach((item) => {
if (this._renderer && epoch === (this._inputEpoch || 0)) this._deliverTouch(item.method, item.event);
});
});
} else {
this._deliverTouch(method, snapshot);
}
},
_deliverTouch(method, event) {
const rect = this._renderer._canvasRect;
[event.touches, event.changedTouches].forEach((touches) => touches.forEach((touch) => {
// CanvasTouch uses local x/y on runtimes that omit client coordinates.
if (typeof touch.clientX !== 'number') touch.clientX = touch.x + rect.left;
if (typeof touch.clientY !== 'number') touch.clientY = touch.y + rect.top;
}));
this._renderer[method](event);
},
_initRenderer() {
const tryQuery = () => {
if (this._detached) return;
wx.createSelectorQuery()
.in(this)
.select('#' + this.data.canvasId)
.fields({ node: true, size: true, rect: true })
.exec((res) => {
if (this._detached) return;
if (!res || !res[0] || !res[0].node) {
setTimeout(tryQuery, 200);
return;
}
const { node, width, height } = res[0];
const platform = new WechatPlatformAdapter({ wx, canvas: node });
this._renderer = new ModelRenderer(node, {
platform,
bgColor: this.data.bgColor,
renderMode: this.data.renderMode,
width,
height,
left: res[0].left || 0,
top: res[0].top || 0,
onUiEvent: (detail) => {
this.triggerEvent('uiEvent', detail);
this.triggerEvent('uievent', detail);
},
onModelEvent: (detail) => {
if (detail.type === 'dragblocked') {
wx.showToast({ title: detail.message, icon: 'none', duration: 2600 });
}
this.triggerEvent('modelevent', detail);
},
});
if (this._cameraModeOverride) this._renderer.setCameraControlMode(this._cameraModeOverride);
const runPendingLoad = (beforeLoad) => {
if (!this._pendingLoad) return;
const { url, options, resolve, reject } = this._pendingLoad;
this._pendingLoad = null;
beforeLoad.then(() => this._doLoad(url, options)).then(resolve).catch(reject);
};
if (this._pendingScene) {
const { source, resolve, reject } = this._pendingScene;
this._pendingScene = null;
const sceneLoad = this._doLoadScene(source);
sceneLoad.then(resolve).catch(reject);
runPendingLoad(sceneLoad);
} else if (this._pendingLoad) {
const { url, options, resolve, reject } = this._pendingLoad;
this._pendingLoad = null;
this._doLoad(url, options).then(resolve).catch(reject);
}
});
};
tryQuery();
},
_doLoad(url, options = {}) {
this.setData({ loading: true, error: '' });
return this._renderer.loadModel(url, options)
.then(() => this.setData({ loading: false }))
.catch((err) => {
console.error('[model-viewer] load error:', err);
this.setData({ loading: false, error: String(err.message || err) });
throw err;
});
},
_doLoadScene(source) {
this.setData({ loading: true, error: '' });
return this._readSceneSource(source)
.then((config) => {
const loaded = this._renderer.loadScene(config);
if (this._cameraModeOverride) this._renderer.setCameraControlMode(this._cameraModeOverride);
return loaded;
})
.then(() => {
this.setData({ loading: false });
this.triggerEvent('sceneLoaded');
})
.catch((err) => {
console.error('[model-viewer] scene load error:', err);
this.setData({ loading: false, error: String(err.message || err) });
this.triggerEvent('sceneError', { error: String(err.message || err) });
throw err;
});
},
_readSceneSource(source) {
if (source && typeof source === 'object') return Promise.resolve(source);
if (!source) return Promise.resolve(DEFAULT_SCENE_CONFIG);
if (typeof source !== 'string') return Promise.reject(new Error('scene source is empty'));
if (/^https?:\/\//i.test(source)) {
const platform = this._renderer && this._renderer._platform;
if (platform) return platform.requestJson(appendNoCacheParam(source));
}
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager();
const paths = source.charAt(0) === '/' ? [source, source.slice(1)] : [source, '/' + source];
const tryRead = (index) => {
if (index >= paths.length) return reject(new Error('scene json not found: ' + source));
fs.readFile({
filePath: paths[index],
encoding: 'utf8',
success: (res) => {
try {
resolve(JSON.parse(res.data));
} catch (e) {
reject(e);
}
},
fail: () => tryRead(index + 1),
});
};
tryRead(0);
});
},
},
});