prophetk-3d-viewer
Version:
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
1,636 lines • 61.9 kB
JavaScript
const THREE = require('../vendor/three-r128.js');
require('../vendor/GLTFLoader.r128.js');
const DecalProjector = require('./DecalProjector.js');
const ModelInteraction = require('./ModelInteraction.js');
const ModelHierarchy = require('./ModelHierarchy.js');
const traverseModel = require('./modelTraversal.js');
const DEFAULT_SCENE_CONFIG = require('./defaultSceneConfig.js');
const PACKAGE_VERSION = require('../version.js');
const CACHE_PREFIX = 'model_cache_';
function validateCameraControlMode(mode) {
if (mode !== 'orbit' && mode !== 'pan') {
throw new RangeError('camera.controlMode must be "orbit" or "pan"');
}
}
function appendNoCacheParam(url) {
const sep = url.indexOf('?') === -1 ? '?' : '&';
return url + sep + '_t=' + Date.now();
}
function createFallbackWechatAdapter(canvas) {
if (typeof wx === 'undefined') {
throw new Error('ModelRenderer requires a platform adapter outside WeChat miniprogram');
}
const WechatPlatformAdapter = require('../adapters/wechat/WechatPlatformAdapter.js');
return new WechatPlatformAdapter({ wx, canvas });
}
/**
* ModelRenderer
* Wraps Three.js scene/camera/renderer and GLB/GLTF model loading for WeChat miniprogram.
* Usage:
* const r = new ModelRenderer(canvas);
* await r.loadModel('https://cdn.example.com/model.glb');
* r.onTouchStart(e); r.onTouchMove(e); r.onTouchEnd(e);
* r.dispose();
*/
class ModelRenderer {
constructor(canvas, options = {}) {
const {
// Actual CSS pixel size of the canvas element. Pass these from the
// SelectorQuery boundingClientRect result so the camera aspect ratio
// matches the rendered area exactly, avoiding stretching.
bgColor = null,
width = 0,
height = 0,
left = 0,
top = 0,
platform = null,
renderMode = 'on-demand',
onUiEvent = null,
onModelEvent = null,
} = options;
this._platform = platform || createFallbackWechatAdapter(canvas);
this._renderMode = renderMode;
if (!canvas.addEventListener) {
canvas.addEventListener = () => {};
canvas.removeEventListener = () => {};
canvas.dispatchEvent = () => {};
}
if (!canvas.style) canvas.style = {};
const info = this._platform.getWindowInfo ? this._platform.getWindowInfo() : {};
const DPR = this._platform.getPixelRatio ? this._platform.getPixelRatio() : (info.pixelRatio || 1);
// Prefer explicitly passed size; fall back to window size only if not provided.
const W = width || info.windowWidth;
const H = height || info.windowHeight;
const gl = this._platform.getContext ? this._platform.getContext(canvas, 'webgl') : canvas.getContext('webgl');
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true });
renderer.setSize(W, H);
renderer.setPixelRatio(DPR);
if (bgColor) renderer.setClearColor(bgColor, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, W / H, 0.01, 1000);
const uiScene = new THREE.Scene();
const uiCamera = new THREE.OrthographicCamera(-W / 2, W / 2, H / 2, -H / 2, 0, 10);
uiCamera.position.z = 1;
const sceneRoot = new THREE.Group();
scene.add(sceneRoot);
this._canvas = canvas;
this._renderer = renderer;
this._scene = scene;
this._sceneRoot = sceneRoot;
this._camera = camera;
this._uiScene = uiScene;
this._uiCamera = uiCamera;
this._uiObjects = [];
this._uiById = {};
this._uiConfig = [];
this._uiEventHandler = typeof onUiEvent === 'function' ? onUiEvent : null;
this._uiPressedObject = null;
this._width = W;
this._height = H;
this._pixelRatio = DPR || 1;
this._canvasRect = { left: left || 0, top: top || 0, width: W, height: H };
this._target = new THREE.Vector3(0, 0, 0);
this._cameraControlMode = 'orbit';
this._currentModel = null;
this._modelNodes = Object.create(null);
this._managedLights = [];
this._pendingDecalConfig = null;
this._decalStatus = {};
this._decalDebugHelper = null;
this._stopTimer = null;
this._orbit = {
theta: 0, phi: Math.PI / 2, radius: 5,
};
this._interaction = new ModelInteraction(this, onModelEvent);
if (this._renderMode === 'continuous') this._startLoop();
}
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Load a GLB/GLTF model from a CDN URL (with local cache).
* Calling this repeatedly appends models to the current scene.
* @param {string|object} url
* @param {object} options
* @returns {Promise<THREE.Object3D>}
*/
loadModel(url, options = {}) {
const item = this._normalizeModelOptions(url, options);
if (!item.url) return Promise.reject(new Error('model url is empty'));
let task;
try {
task = this._getHierarchy().load(item, item.id || item.name || this._nextModelId(), item.normalize !== false);
} catch (error) { return Promise.reject(error); }
return task.then((object) => {
if (this._modelNodes[object.userData.modelId] !== object) throw new Error('Model load cancelled');
this._currentModel = object;
this._applyActiveDecalToModel(object);
this._requestRender();
return object;
});
}
/**
* Remove current model and load a new one.
* @param {string} url
* @returns {Promise<void>}
*/
switchModel(url, options = {}) {
this._clearCurrentModel();
return this.loadModel(url, options);
}
/**
* Clear loaded model(s) by id or url.
* @param {string|object} target model id/url string, or { id, url }
* @returns {number} Removed model count.
*/
clearModel(target) {
const ids = this._findModelIds(target);
return this._getHierarchy().remove(ids);
}
/**
* Clear all loaded models while keeping renderer, lights, env map and active decal.
* @returns {number} Removed model count.
*/
clearAllModels() {
this._sceneGeneration = (this._sceneGeneration || 0) + 1;
this._sceneRequest = (this._sceneRequest || 0) + 1;
this._interaction.reset('modelremoved');
const hierarchy = this._getHierarchy();
const count = hierarchy.remove(Array.from(hierarchy.records.keys()));
this._currentModel = null;
return count;
}
_getHierarchy() {
if (!this._hierarchy) this._hierarchy = new ModelHierarchy(this);
return this._hierarchy;
}
setParent(modelId, parentId) {
if (this._disposed) throw new Error('renderer disposed');
this._getHierarchy().setParent(modelId, parentId);
}
SetParent(modelId, parentId) { this.setParent(modelId, parentId); }
/**
* Load a full scene description from parsed JSON or a remote JSON URL.
* Existing imperative APIs still work after this; the first model becomes
* the current decal target unless decal.target or decal_projector.target
* names another model id.
* @param {string|object} config
* @returns {Promise<void>}
*/
loadScene(config) {
if (this._disposed) return Promise.reject(new Error('renderer disposed'));
if (typeof config === 'string') {
const request = this._sceneRequest = (this._sceneRequest || 0) + 1;
return this._readSceneConfig(config).then((sceneConfig) => {
if (this._disposed || this._sceneRequest !== request) throw new Error('Scene load cancelled');
return this.loadScene(sceneConfig);
});
}
console.info('[model-viewer] npm version:', PACKAGE_VERSION);
config = config || DEFAULT_SCENE_CONFIG;
const models = Array.isArray(config.models) ? config.models : [];
try { ModelHierarchy.validate(models); } catch (error) { return Promise.reject(error); }
const cameraMode = config.camera && config.camera.controlMode;
validateCameraControlMode(cameraMode === undefined ? 'orbit' : cameraMode);
this._clearSceneContent();
const generation = this._sceneGeneration;
this._applyRendererSettings(config.renderer || config);
const environment = config.environment || {};
this._interaction.configure(environment);
const envUrl = environment.url || config.envMap || config.skybox;
if (envUrl) this.setEnvMap(envUrl, !!environment.showSkybox);
this._applyLights(config.lights);
this._applyCamera(config.camera);
const uiLoad = this._applyUi(config.ui);
const projectorDecal = config.decal_projector || config.decalProjector;
this._pendingDecalConfig = projectorDecal
? { ...projectorDecal, projectionMode: projectorDecal.projectionMode || projectorDecal.mode || 'projector' }
: (config.decal || null);
this._setDecalStatusFromConfig(this._pendingDecalConfig);
this._refreshDecalStatusLabel();
return Promise.all(models.map((item, index) => this._loadSceneModel(item, index))).then((objects) => {
if (this._disposed || this._sceneGeneration !== generation) throw new Error('Scene load cancelled');
if (objects.some(object => object && this._modelNodes[object.userData.modelId] !== object)) {
throw new Error('Scene model load cancelled');
}
const decalTarget = this._pendingDecalConfig && this._pendingDecalConfig.target;
this._currentModel = (decalTarget && this._modelNodes[decalTarget]) || objects[0] || null;
if (this._pendingDecalConfig && this._pendingDecalConfig.url) {
const decal = this._normalizeDecalConfig(this._pendingDecalConfig);
return this.applyDecal(this._pendingDecalConfig.url, decal).then(() => {
if (this._disposed || this._sceneGeneration !== generation) throw new Error('Scene load cancelled');
this._updateDecalDebugHelper(this._pendingDecalConfig);
return uiLoad;
});
}
this._clearDecalDebugHelper();
this._requestRender();
return uiLoad;
});
}
_readSceneConfig(source) {
if (!source) return Promise.resolve(DEFAULT_SCENE_CONFIG);
if (typeof source !== 'string') return Promise.resolve(source || DEFAULT_SCENE_CONFIG);
if (!/^https?:\/\//i.test(source)) {
return Promise.reject(new Error('scene config url must start with http:// or https://'));
}
return this._platform.requestJson(appendNoCacheParam(source));
}
dispose() {
this._disposed = true;
this._clearSceneContent('dispose');
this._interaction.reset('dispose');
this._interaction.handler = null;
this._stopLoop();
if (this._renderer) {
// Patch the canvas on the Three.js renderer context to prevent it from
// calling cancelAnimationFrame on a null node during dispose (WeChat mp bug).
try {
const ctx = this._renderer.getContext();
if (ctx && ctx.canvas) {
ctx.canvas.cancelAnimationFrame = () => {};
ctx.canvas.requestAnimationFrame = () => 0;
}
} catch (e) {}
try { this._renderer.dispose(); } catch (e) {}
}
if (this._envTexture) this._envTexture.dispose();
if (this._decal) this._decal.dispose();
this._clearUi();
this._canvas = null;
}
setUiEventHandler(handler) {
this._uiEventHandler = typeof handler === 'function' ? handler : null;
}
getSelectedModelId() {
return this._interaction.selectedId;
}
/** JSON snapshot of loaded model roots, in the same units as scene config. */
getModelTransforms() {
const degrees = 180 / Math.PI;
const rotation = new THREE.Euler();
return JSON.stringify(Object.keys(this._modelNodes).map((id) => {
const object = this._modelNodes[id];
rotation.setFromQuaternion(object.quaternion, 'XYZ');
return {
id,
...(this._hierarchy && this._hierarchy.records.get(id) && this._hierarchy.records.get(id).parent !== null
? { parent: this._hierarchy.records.get(id).parent } : {}),
position: object.position.toArray(),
rotation: [rotation.x * degrees, rotation.y * degrees, rotation.z * degrees],
scale: object.scale.toArray(),
};
}));
}
clearSelection() {
this._interaction.clearSelection();
}
getCameraControlMode() {
return this._cameraControlMode;
}
setCameraControlMode(mode) {
validateCameraControlMode(mode);
if (mode === this._cameraControlMode) return;
this._cameraControlMode = mode;
this._interaction.onCameraModeChange();
}
setUiLabelText(id, text) {
const object = this._uiById && this._uiById[id];
if (!object || !object.userData || object.userData.type !== 'label') return false;
object.userData.text = String(text == null ? '' : text);
if (object.userData.config) object.userData.config.text = object.userData.text;
this._redrawLabel(object);
this._requestRender();
return true;
}
resize(width, height, rect = {}) {
if (!width || !height) return;
if (width !== this._width || height !== this._height) this._interaction.cancel('resize');
this._width = width;
this._height = height;
this._canvasRect = {
left: rect.left === undefined ? this._canvasRect.left : rect.left,
top: rect.top === undefined ? this._canvasRect.top : rect.top,
width,
height,
};
this._renderer.setSize(width, height);
this._camera.aspect = width / height;
this._camera.updateProjectionMatrix();
this._uiCamera.left = -width / 2;
this._uiCamera.right = width / 2;
this._uiCamera.top = height / 2;
this._uiCamera.bottom = -height / 2;
this._uiCamera.updateProjectionMatrix();
if (this._uiConfig && this._uiConfig.length) this._applyUi(this._uiConfig);
this._requestRender();
}
/**
* Set or replace the environment map at runtime.
* @param {string} url Equirectangular PNG/JPG URL
* @param {boolean} showSkybox Also render as visible background
*/
setEnvMap(url, showSkybox = false) {
this._loadEnvMap(url, showSkybox);
}
// ── Decal API ──────────────────────────────────────────────────────────────
/**
* Apply a cylindrical decal to the current model.
* @param {string} url URL of the decal texture (PNG with alpha recommended)
* @param {object} options
* @param {number} options.angle Center angle in radians around world Y (default 0)
* @param {number} options.height Center height in world units (default 0)
* @param {number} options.arcWidth Angular width in radians (default π/3)
* @param {number} options.heightRange Height span in world units (default 1.0)
* @param {number} options.opacity Blend opacity 0–1 (default 1.0)
* @returns {Promise<void>}
*/
applyDecal(url, options = {}) {
const generation = this._sceneGeneration;
return this._fetchFile(url).then((localPath) => {
if (this._disposed || this._sceneGeneration !== generation) throw new Error('Decal load cancelled');
return new Promise((resolve, reject) => {
const img = this._platform.createImage(this._canvas);
img.onload = () => {
if (this._disposed || this._sceneGeneration !== generation) {
reject(new Error('Decal load cancelled'));
return;
}
const tex = new THREE.Texture(img);
tex.encoding = THREE.sRGBEncoding;
tex.needsUpdate = true;
// Estimate cylinder radius from model bounding box (XZ plane average)
let cylinderRadius = 1.0;
if (this._currentModel) {
const box = new THREE.Box3();
this._currentModel.updateWorldMatrix(true, true);
traverseModel(this._currentModel, (mesh) => {
if (!mesh.isMesh || !mesh.geometry) return;
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
box.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld));
});
const size = box.getSize(new THREE.Vector3());
cylinderRadius = (size.x + size.z) / 4;
console.log('[Decal] model size:', size.x.toFixed(3), size.y.toFixed(3), size.z.toFixed(3), 'radius:', cylinderRadius.toFixed(3));
}
if (this._decal) this._decal.dispose();
this._decal = new DecalProjector(tex, {
...options,
cylinderRadius,
textureWidth: img.width,
textureHeight: img.height,
});
console.log('[Decal] texture size:', img.width, img.height);
this._decal.setOptions(options);
if (this._currentModel) this._decal.applyToModel(this._currentModel);
this._requestRender();
resolve();
};
img.onerror = reject;
img.src = localPath;
});
});
}
/**
* Update decal position and scale without reloading the texture.
* @param {number} angle Center angle in radians around world Y
* @param {number} height Center height in world units
* @param {number} scaleX Horizontal scale (default 1.0)
* @param {number} scaleY Vertical scale (default 1.0)
*/
updateDecal(angle, height, scaleX = 1.0, scaleY = 1.0) {
if (!this._decal) return;
if (angle && typeof angle === 'object') {
this._decal.setOptions(angle);
this._updateDecalStatus(angle);
this._refreshDecalStatusLabel();
this._requestRender();
return;
}
this._decal.setAngle(angle);
this._decal.setHeight(height);
this._decal.setScaleX(scaleX);
this._decal.setScaleY(scaleY);
this._updateDecalStatus({ angle, height, scaleX, scaleY });
this._refreshDecalStatusLabel();
this._requestRender();
}
updateProjectorDecal(options = {}) {
if (!this._decal) return;
this._decal.setOptions({ ...options, projectionMode: 'projector' });
this._updateDecalStatus({ ...options, projectionMode: 'projector' });
this._refreshDecalStatusLabel();
this._requestRender();
}
/** Remove the current decal from the model. */
removeDecal() {
if (this._decal) {
this._decal.remove();
this._decal = null;
this._clearDecalDebugHelper();
this._requestRender();
}
}
// ── Input shared by Mini Program and Web ───────────────────────────────────
onTouchStart(e) {
if (!this._disposed) this._interaction.start(e);
}
onTouchMove(e) {
if (!this._disposed) this._interaction.move(e);
}
onTouchEnd(e) {
if (!this._disposed) this._interaction.end(e);
}
onTouchCancel(e, reason = 'touchcancel') {
if (!this._disposed) this._interaction.cancel(reason);
}
/** Wheel up moves closer to the target; wheel down moves farther away. */
onWheel(e = {}) {
if (this._disposed || this._interaction.blocksWheel()) return false;
if (!Number.isFinite(e.deltaY)) return false;
// DOM_DELTA_PIXEL = 0, DOM_DELTA_LINE = 1, DOM_DELTA_PAGE = 2.
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? this._getCanvasRect().height : 1;
return this._zoomCameraBy(e.deltaY * unit * 0.01);
}
_zoomCameraBy(delta) {
if (!Number.isFinite(delta) || delta === 0) return false;
const radius = Math.max(1, Math.min(30, this._orbit.radius + delta));
if (radius === this._orbit.radius) return false;
this._orbit.radius = radius;
this._requestRender();
return true;
}
// ── Private ────────────────────────────────────────────────────────────────
_loadSceneModel(item = {}, index) {
if (!item.url) return Promise.resolve(null);
try {
return this._getHierarchy().load(item, item.id || item.name || ('model_' + index), item.normalize === true);
} catch (error) { return Promise.reject(error); }
}
_applyUi(items) {
this._clearUi();
this._uiConfig = Array.isArray(items) ? items.slice() : [];
if (!Array.isArray(items) || !items.length) {
this._requestRender();
return Promise.resolve();
}
const tasks = items
.filter((item) => item && (item.type === 'button' || item.type === 'slider' || item.type === 'slide' || item.type === 'label'))
.map((item, index) => Promise.resolve()
.then(() => this._createUiControl(item, index))
.catch((err) => {
console.warn('[ModelRenderer] ui control failed:', err);
}));
return Promise.all(tasks).then(() => {
this._requestRender();
});
}
_createUiControl(config = {}, index) {
if (config.type === 'label') return this._createUiLabel(config, index);
if (config.type === 'slider' || config.type === 'slide') return this._createUiSlider(config, index);
return this._createUiButton(config, index);
}
_createUiButton(config = {}, index) {
const size = this._normalizedSizeFromArray(config.scale, [0.2, 0.08]);
const position = this._normalizedPositionFromArray(config.position, [0, 0]);
const alpha = config.alpha === undefined ? 1 : Math.max(0, Math.min(1, Number(config.alpha) || 0));
const finish = (image) => {
const texture = this._createButtonTexture(config, size, image);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: alpha,
depthTest: false,
depthWrite: false,
});
const geometry = new THREE.PlaneGeometry(size.width, size.height);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position.x + size.width / 2 - this._width / 2, position.y + size.height / 2 - this._height / 2, 0);
mesh.renderOrder = 1000 + index;
mesh.userData = {
ui: true,
type: 'button',
event: config.event || '',
text: config.text || '',
alpha,
baseScaleX: 1,
baseScaleY: 1,
bounds: {
left: position.x,
right: position.x + size.width,
bottom: position.y,
top: position.y + size.height,
},
};
this._uiScene.add(mesh);
this._uiObjects.push(mesh);
this._registerUiObject(config, mesh);
};
if (!config.img) {
finish(null);
return Promise.resolve();
}
return this._loadUiImage(config.img).then(finish).catch((err) => {
console.warn('[ModelRenderer] ui button image failed:', err);
finish(null);
});
}
_createUiSlider(config = {}, index) {
const size = this._normalizedSizeFromArray(config.scale, [0.4, 0.08]);
const position = this._normalizedPositionFromArray(config.position, [0, 0]);
const alpha = config.alpha === undefined ? 1 : Math.max(0, Math.min(1, Number(config.alpha) || 0));
const min = this._finiteNumber(config.min, 0);
const max = this._finiteNumber(config.max, 1);
const value = this._clampSliderValue(this._finiteNumber(config.value, min), min, max, config.step);
const textureInfo = this._createSliderTextureInfo(config, size, value, min, max);
const material = new THREE.MeshBasicMaterial({
map: textureInfo.texture,
transparent: true,
opacity: alpha,
depthTest: false,
depthWrite: false,
});
const geometry = new THREE.PlaneGeometry(size.width, size.height);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position.x + size.width / 2 - this._width / 2, position.y + size.height / 2 - this._height / 2, 0);
mesh.renderOrder = 1000 + index;
mesh.userData = {
ui: true,
type: 'slider',
event: config.event || '',
text: config.text || '',
alpha,
min,
max,
value,
step: config.step,
target: config.target || '',
config,
size,
textureInfo,
bounds: {
left: position.x,
right: position.x + size.width,
bottom: position.y,
top: position.y + size.height,
},
};
this._uiScene.add(mesh);
this._uiObjects.push(mesh);
this._registerUiObject(config, mesh);
this._applySliderValue(mesh, value, false);
return Promise.resolve();
}
_createUiLabel(config = {}, index) {
const size = this._normalizedSizeFromArray(config.scale, [0.3, 0.08]);
const position = this._normalizedPositionFromArray(config.position, [0, 0]);
const alpha = config.alpha === undefined ? 1 : Math.max(0, Math.min(1, Number(config.alpha) || 0));
const text = String(config.text == null ? '' : config.text);
const textureInfo = this._createLabelTextureInfo(config, size, text);
const material = new THREE.MeshBasicMaterial({
map: textureInfo.texture,
transparent: true,
opacity: alpha,
depthTest: false,
depthWrite: false,
});
const geometry = new THREE.PlaneGeometry(size.width, size.height);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position.x + size.width / 2 - this._width / 2, position.y + size.height / 2 - this._height / 2, 0);
mesh.renderOrder = 1000 + index;
mesh.userData = {
ui: true,
type: 'label',
id: config.id || '',
text,
alpha,
config,
size,
textureInfo,
interactive: false,
bounds: {
left: position.x,
right: position.x + size.width,
bottom: position.y,
top: position.y + size.height,
},
};
this._uiScene.add(mesh);
this._uiObjects.push(mesh);
this._registerUiObject(config, mesh);
return Promise.resolve();
}
_createButtonTexture(config, size, image) {
const textureScale = this._getUiTextureScale();
const textureWidth = Math.max(1, Math.round(size.width * textureScale));
const textureHeight = Math.max(1, Math.round(size.height * textureScale));
const canvas = this._createTextureCanvas(textureWidth, textureHeight);
const ctx = canvas.getContext && canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, textureWidth, textureHeight);
if (ctx.setTransform) ctx.setTransform(textureScale, 0, 0, textureScale, 0, 0);
else ctx.scale(textureScale, textureScale);
if (image) {
ctx.drawImage(image, 0, 0, size.width, size.height);
} else {
this._drawRoundedRect(ctx, 0, 0, size.width, size.height, Math.min(8, size.height / 2));
ctx.fillStyle = config.backgroundColor || 'rgba(30, 30, 30, 0.82)';
ctx.fill();
ctx.strokeStyle = config.borderColor || 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
if (config.text) {
this._drawButtonText(ctx, String(config.text), size, config);
}
}
const texture = new THREE.Texture(canvas);
texture.encoding = THREE.sRGBEncoding;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
}
_getUiTextureScale() {
const ratio = Number(this._pixelRatio) || 1;
return Math.max(1, Math.min(4, ratio));
}
_createSliderTextureInfo(config, size, value, min, max) {
const textureScale = this._getUiTextureScale();
const textureWidth = Math.max(1, Math.round(size.width * textureScale));
const textureHeight = Math.max(1, Math.round(size.height * textureScale));
const canvas = this._createTextureCanvas(textureWidth, textureHeight);
const ctx = canvas.getContext && canvas.getContext('2d');
const texture = new THREE.Texture(canvas);
texture.encoding = THREE.sRGBEncoding;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
this._drawSliderTexture(ctx, config, size, value, min, max, textureScale, textureWidth, textureHeight);
texture.needsUpdate = true;
return { canvas, ctx, texture, textureScale, textureWidth, textureHeight };
}
_createLabelTextureInfo(config, size, text) {
const textureScale = this._getUiTextureScale();
const textureWidth = Math.max(1, Math.round(size.width * textureScale));
const textureHeight = Math.max(1, Math.round(size.height * textureScale));
const canvas = this._createTextureCanvas(textureWidth, textureHeight);
const ctx = canvas.getContext && canvas.getContext('2d');
const texture = new THREE.Texture(canvas);
texture.encoding = THREE.sRGBEncoding;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
this._drawLabelTexture(ctx, config, size, text, textureScale, textureWidth, textureHeight);
texture.needsUpdate = true;
return { canvas, ctx, texture, textureScale, textureWidth, textureHeight };
}
_drawLabelTexture(ctx, config, size, text, textureScale, textureWidth, textureHeight) {
if (!ctx) return;
ctx.clearRect(0, 0, textureWidth, textureHeight);
if (ctx.setTransform) ctx.setTransform(textureScale, 0, 0, textureScale, 0, 0);
else ctx.scale(textureScale, textureScale);
const padding = Number.isFinite(Number(config.padding)) ? Number(config.padding) : Math.max(6, size.height * 0.14);
const radius = Number.isFinite(Number(config.radius)) ? Number(config.radius) : Math.min(8, size.height / 2);
if (config.backgroundColor) {
this._drawRoundedRect(ctx, 0, 0, size.width, size.height, radius);
ctx.fillStyle = config.backgroundColor;
ctx.fill();
}
if (config.borderColor) {
this._drawRoundedRect(ctx, 0.5, 0.5, Math.max(0, size.width - 1), Math.max(0, size.height - 1), radius);
ctx.strokeStyle = config.borderColor;
ctx.lineWidth = Number(config.borderWidth) || 1;
ctx.stroke();
}
const fontSize = Math.max(8, Math.min(size.height * 0.7, Number(config.fontSize) || 16));
ctx.font = `${fontSize}px sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillStyle = config.color || '#ffffff';
const align = config.align || 'left';
ctx.textAlign = align;
const x = align === 'center' ? size.width / 2 : (align === 'right' ? size.width - padding : padding);
ctx.fillText(String(text), x, size.height / 2, Math.max(0, size.width - padding * 2));
}
_drawSliderTexture(ctx, config, size, value, min, max, textureScale, textureWidth, textureHeight) {
if (!ctx) return;
ctx.clearRect(0, 0, textureWidth, textureHeight);
if (ctx.setTransform) ctx.setTransform(textureScale, 0, 0, textureScale, 0, 0);
else ctx.scale(textureScale, textureScale);
const label = config.text ? String(config.text) : '';
const padX = Math.max(8, size.height * 0.22);
const trackHeight = Math.max(4, size.height * 0.14);
const labelHeight = label ? Math.min(size.height * 0.38, Number(config.fontSize) || 14) : 0;
const trackY = label ? size.height * 0.68 : size.height * 0.5;
const trackX = padX;
const trackW = Math.max(1, size.width - padX * 2);
const radius = trackHeight / 2;
const t = max === min ? 0 : (value - min) / (max - min);
const thumbX = trackX + trackW * Math.max(0, Math.min(1, t));
const thumbR = Math.max(7, size.height * 0.22);
if (label) {
ctx.font = `${labelHeight}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = config.color || '#ffffff';
const valueText = this._formatSliderValue(value);
ctx.fillText(`${label} ${valueText}`, padX, Math.max(labelHeight / 2, size.height * 0.26), size.width - padX * 2);
}
this._drawRoundedRect(ctx, trackX, trackY - trackHeight / 2, trackW, trackHeight, radius);
ctx.fillStyle = config.trackColor || 'rgba(255, 255, 255, 0.28)';
ctx.fill();
this._drawRoundedRect(ctx, trackX, trackY - trackHeight / 2, Math.max(radius * 2, thumbX - trackX), trackHeight, radius);
ctx.fillStyle = config.fillColor || '#4da3ff';
ctx.fill();
ctx.beginPath();
ctx.arc(thumbX, trackY, thumbR, 0, Math.PI * 2);
ctx.fillStyle = config.thumbColor || '#ffffff';
ctx.fill();
ctx.lineWidth = Math.max(1, size.height * 0.03);
ctx.strokeStyle = config.thumbBorderColor || 'rgba(0, 0, 0, 0.2)';
ctx.stroke();
}
_loadUiImage(url) {
return this._fetchFile(url).then((localPath) => new Promise((resolve, reject) => {
const img = this._platform.createImage(this._canvas);
img.onload = () => resolve(img);
img.onerror = reject;
img.src = localPath;
}));
}
_createTextureCanvas(width, height) {
if (this._platform.createCanvas) {
const canvas = this._platform.createCanvas(width, height);
canvas.width = width;
canvas.height = height;
return canvas;
}
if (typeof document !== 'undefined' && document.createElement) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
throw new Error('platform does not support texture canvas');
}
_drawRoundedRect(ctx, x, y, width, height, radius) {
const r = Math.max(0, Math.min(radius, width / 2, height / 2));
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
_drawButtonText(ctx, text, size, config) {
const fontSize = Math.max(10, Math.min(size.height * 0.55, Number(config.fontSize) || 16));
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = config.color || '#ffffff';
ctx.fillText(text, size.width / 2, size.height / 2, Math.max(0, size.width - 8));
}
_clearUi() {
if (!this._uiObjects) return;
this._uiObjects.forEach((object) => {
if (object.parent) object.parent.remove(object);
if (object.material && object.material.map) object.material.map.dispose();
this._disposeObject(object);
});
this._uiObjects = [];
this._uiById = {};
this._uiTouchTarget = null;
this._uiPressedObject = null;
}
_registerUiObject(config, object) {
if (!config || !config.id) return;
this._uiById[config.id] = object;
}
_updateSliderFromPoint(object, point, emit) {
if (!object || !point || !object.userData) return;
const b = object.userData.bounds;
const x = Math.max(b.left, Math.min(b.right, point.x));
const t = b.right === b.left ? 0 : (x - b.left) / (b.right - b.left);
const min = object.userData.min;
const max = object.userData.max;
const value = this._clampSliderValue(min + (max - min) * t, min, max, object.userData.step);
if (value === object.userData.value) return;
object.userData.value = value;
this._redrawSlider(object);
this._applySliderValue(object, value, emit);
this._requestRender();
}
_redrawSlider(object) {
const data = object.userData;
const info = data.textureInfo;
this._drawSliderTexture(info.ctx, data.config, data.size, data.value, data.min, data.max, info.textureScale, info.textureWidth, info.textureHeight);
info.texture.needsUpdate = true;
}
_redrawLabel(object) {
const data = object.userData;
const info = data.textureInfo;
this._drawLabelTexture(info.ctx, data.config, data.size, data.text, info.textureScale, info.textureWidth, info.textureHeight);
info.texture.needsUpdate = true;
}
_applySliderValue(object, value, emit) {
const eventKey = object.userData.event;
const target = object.userData.target;
if (target === 'decal.scaleX' || eventKey === 'event_slider_decal_scale_x') {
this.updateDecal({ scaleX: value });
} else if (target === 'decal.scaleY' || eventKey === 'event_slider_decal_scale_y') {
this.updateDecal({ scaleY: value });
}
if (emit) this._emitUiEvent(object, null);
}
_setDecalStatusFromConfig(config = {}) {
this._decalStatus = {};
if (!config) return;
const normalized = this._normalizeDecalConfig(config);
this._updateDecalStatus(normalized);
}
_updateDecalStatus(options = {}) {
if (!this._decalStatus) this._decalStatus = {};
const map = {
scaleX: ['scaleX'],
scaleY: ['scaleY'],
opacity: ['opacity'],
projectorWidth: ['projectorWidth', 'width'],
projectorHeight: ['projectorHeight'],
projectorRollDeg: ['projectorRollDeg', 'rollDeg'],
projectorRoll: ['projectorRoll', 'roll'],
};
Object.keys(map).forEach((key) => {
const names = map[key];
for (let i = 0; i < names.length; i += 1) {
const value = options[names[i]];
if (value !== undefined && value !== null && Number.isFinite(Number(value))) {
this._decalStatus[key] = Number(value);
return;
}
}
});
if (this._decalStatus.projectorRollDeg === undefined && this._decalStatus.projectorRoll !== undefined) {
this._decalStatus.projectorRollDeg = this._decalStatus.projectorRoll * 180 / Math.PI;
}
}
_refreshDecalStatusLabel() {
if (!this._uiById || !this._uiById.decal_status_label) return;
const s = this._decalStatus || {};
const text = [
'decal',
`scaleX=${this._formatStatusNumber(s.scaleX, 1)}`,
`scaleY=${this._formatStatusNumber(s.scaleY, 1)}`,
`opacity=${this._formatStatusNumber(s.opacity, 1)}`,
`projector=${this._formatStatusNumber(s.projectorWidth, 1)}x${this._formatStatusNumber(s.projectorHeight, 1)}`,
`roll=${this._formatStatusNumber(s.projectorRollDeg, 0)}`,
].join(' ');
this.setUiLabelText('decal_status_label', text);
}
_formatStatusNumber(value, fallback) {
const n = Number.isFinite(Number(value)) ? Number(value) : fallback;
return String(Math.round(n * 100) / 100);
}
_setUiPressed(object, pressed) {
if (!object || !object.userData) return;
if (pressed && this._uiPressedObject === object) return;
if (!pressed && this._uiPressedObject !== object) return;
const alpha = object.userData.alpha === undefined ? 1 : object.userData.alpha;
if (pressed) {
object.scale.set(0.96, 0.96, 1);
if (object.material) object.material.opacity = alpha * 0.72;
this._uiPressedObject = object;
} else {
object.scale.set(1, 1, 1);
if (object.material) object.material.opacity = alpha;
this._uiPressedObject = null;
}
this._requestRender();
}
_normalizedSizeFromArray(value, fallback) {
const source = Array.isArray(value) ? value : fallback;
const widthRatio = Number.isFinite(Number(source[0])) ? Number(source[0]) : fallback[0];
const heightRatio = Number.isFinite(Number(source[1])) ? Number(source[1]) : fallback[1];
return {
width: Math.max(1, Math.round(widthRatio * this._width)),
height: Math.max(1, Math.round(heightRatio * this._height)),
};
}
_normalizedPositionFromArray(value, fallback) {
const source = Array.isArray(value) ? value : fallback;
const xRatio = Number.isFinite(Number(source[0])) ? Number(source[0]) : fallback[0];
const yRatio = Number.isFinite(Number(source[1])) ? Number(source[1]) : fallback[1];
return {
x: xRatio * this._width,
y: yRatio * this._height,
};
}
_finiteNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
_clampSliderValue(value, min, max, step) {
const low = Math.min(min, max);
const high = Math.max(min, max);
let v = Math.max(low, Math.min(high, value));
const s = Number(step);
if (Number.isFinite(s) && s > 0) {
v = Math.round((v - min) / s) * s + min;
v = Math.max(low, Math.min(high, v));
}
return Number(v.toFixed(6));
}
_formatSliderValue(value) {
const rounded = Math.round(value * 100) / 100;
return String(rounded);
}
_eventPointToCanvas(e, preferChangedTouches = false) {
const list = preferChangedTouches && e.changedTouches && e.changedTouches.length ? e.changedTouches : e.touches;
const touch = list && list.length ? list[0] : (e.changedTouches && e.changedTouches[0]);
if (!touch) return null;
const rect = this._getCanvasRect();
return {
x: (touch.clientX - rect.left) * this._width / rect.width,
y: (touch.clientY - rect.top) * this._height / rect.height,
cssX: touch.clientX - rect.left,
cssY: touch.clientY - rect.top,
};
}
_getCanvasRect() {
if (this._canvas && this._canvas.getBoundingClientRect) {
const rect = this._canvas.getBoundingClientRect();
return {
left: rect.left || 0,
top: rect.top || 0,
width: rect.width || this._width,
height: rect.height || this._height,
};
}
return this._canvasRect;
}
_hitTestUi(canvasX, canvasY) {
const x = canvasX;
const y = this._height - canvasY;
for (let i = this._uiObjects.length - 1; i >= 0; i -= 1) {
const object = this._uiObjects[i];
const b = object.userData && object.userData.bounds;
if (object.userData && object.userData.interactive === false) continue;
if (b && x >= b.left && x <= b.right && y >= b.bottom && y <= b.top) return object;
}
return null;
}
_emitUiEvent(object, point) {
const detail = {
type: object.userData.type,
event: object.userData.event,
text: object.userData.text,
value: object.userData.value,
x: point && point.x,
y: point && point.y,
};
if (this._uiEventHandler) this._uiEventHandler(detail);
}
_applyActiveDecalToModel(object) {
if (this._decal && object) this._decal.applyToModel(object);
}
_normalizeModelOptions(url, options = {}) {
if (url && typeof url === 'object') return url;
return Object.assign({}, options, { url });
}
_nextModelId() {
let index = Object.keys(this._modelNodes).length;
let id = 'model_' + index;
while (this._modelNodes[id] || this._getHierarchy().records.has(id)) {
index += 1;
id = 'model_' + index;
}
return id;
}
_findModelIds(target) {
if (!target) return [];
const records = this._getHierarchy().records;
const ids = Array.from(records.keys());
if (typeof target === 'string') {
if (records.has(target) || Array.from(records.values()).some(record => record.parent === target)) return [target];
} else if (target.id) {
return [target.id];
}
const url = typeof target === 'string' ? target : target.url;
if (!url) return [];
return ids.filter(id => records.get(id).url === url ||
records.get(id).object && records.get(id).object.userData.modelUrl === url);
}
_loadGltfObject(url, options = {}) {
return this._fetchArrayBuffer(url).then(buffer => {
return new Promise((resolve, reject) => {
const loader = new THREE.GLTFLoader();
if (THREE.GLTFLoader.setCanvas) THREE.GLTFLoader.setCanvas(this._canvas);
loader.parse(buffer, '', (gltf) => {
const object = gltf.scene;
if (options.normalize) this._centerAndScale(object);
resolve(object);
}, reject);
});
});
}
_applyTransform(object, item) {
if (Array.isArray(item.position)) object.position.fromArray(item.position);
if (Array.isArray(item.rotation)) {
object.rotation.set(
(Number(item.rotation[0]) || 0) * Math.PI / 180,
(Number(item.rotation[1]) || 0) * Math.PI / 180,
(Number(item.rotation[2]) || 0) * Math.PI / 180
);
}
if (Array.isArray(item.scale)) {
object.scale.fromArray(item.scale);
} else if (typeof item.scale === 'number') {
object.scale.setScalar(item.scale);
}
}
_normalizeDecalConfig(config = {}) {
const angle = config.angle !== undefined ? config.angle :
(config.decalAngleDeg !== undefined ? config.decalAngleDeg * Math.PI / 180 : undefined);
return {
angle,
height: config.height !== undefined ? config.height : config.decalHeight,
scaleX: config.scaleX !== undefined ? config.scaleX : config.decalScaleX,
scaleY: config.scaleY !== undefined ? config.scaleY : config.decalScaleY,
arcWidth: config.arcWidth,
heightRange: config.heightRange,
opacity: config.opacity,
projectionMode: config.projectionMode || config.mode,
mode: config.mode,
position: config.position,
direction: config.direction,
targetPoint: config.targetPoint,
rotation: config.rotation,
roll: config.roll,
rollDeg: config.rollDeg,
up: config.up,
width: config.width,
projectorPosition: config.projectorPosition,
projectorDirection: config.projectorDirection,
projectorTarget: config.projectorTarget,
projectorRotation: config.projectorRotation,
projectorRoll: config.projectorRoll,
projectorRollDeg: config.projectorRollDeg,
projectorUp: config.projectorUp,
projectorWidth: config.projectorWidth,
projectorHeight: config.projectorHeight,
projectorNormalThreshold: config.projectorNormalThreshold,
normalThreshold: config.normalThreshold,
};
}
_updateDecalDebugHelper(config = {}) {
this._clearDecalDebugHelper();
if (!config.debug) return;
const origin = this._vectorFromArray(config.projectorPosition || config.position, [0, 0, 1]);
const debugVector = this._resolveProjectorDebugVector(config, origin);
const length = this._positiveNumber(
config.debugLength,
debugVector.length || Math.max(config.projectorWidth || config.width || 0, config.projectorHeight || config.height || 0, 0.5)
);
const color = config.debugColor || 0xff3344;
const headLength = Math.min(length * 0.25, 0.2);
const headWidth = Math.min(length * 0.12, 0.1);
this._decalDebugHelper = new THREE.ArrowHelper(debugVector.direction, origin, length, color, headLength, headWidth);
this._sceneRoot.add(this._decalDebugHelper);
this._requestRender();
}
_clearDecalDebugHelper() {
if (!this._decalDebugHelper) return;
if (this._decalDebugHelper.parent) {
this._decalDebugHelper.parent.remove(this._decalDebugHelper);
}
this._disposeObject(this._decalDebugHelper);
this._decalDebugHelper = null;
}
_resolveProjectorDebugVector(config = {}, origin) {
if (Array.isArray(config.projectorTarget) || Array.isArray(config.targetPoint)) {
const target = this._vectorFromArray(config.projectorTarget || config.targetPoint, [0, 0, 0]);
const direction = target.sub(origin);
const length = direction.length();
if (length > 0) return { direction: direction.normalize(), length };
}
if (Array.isArray(config.projectorDirection) || Array.isArray(config.direction)) {
const direction = this._vectorFromArray(config.projectorDirection || config.direction, [0, 0, -1]);
if (direction.lengthSq() > 0) return { direction: direction.normalize(), length: 0 };
}
const direction = new THREE.Vector3(0, 0, -1);
const rotation = config.projectorRotation || config.rotation;
if (Array.isArray(rotation)) {
direction.applyEuler(new THREE.Euler(
Number(rotation[0]) || 0,
Number(rotation[1]) || 0,
Number(rotation[2]) || 0,
'XYZ'
));
}
return { direction: direction.normalize(), length: 0 };
}
_vectorFromArray(value, fallback) {
const source = Array.isArray(value) && value.length >= 3 ? value : fallback;
return new THREE.Vector3(
Number(source[0]) || 0,
Number(source[1]) || 0,
Number(source[2]) || 0
);
}
_positiveNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
_applyRendererSettings(settings = {}) {
const clearColor = settings.clearColor || settings.backgroundColor;
if (clearColor) {
const clearAlpha = settings.clearAlpha === undefined ? 1 : settings.clearAlpha;
this._renderer.setClearColor(clearColor, clearAlpha);
this._scene.background = null;
}
if (settings.shadowMapEnabled !== undefined) {
this._renderer.shadowMap.enabled = !!settings.shadowMapEnabled;
}
if (settings.outputEncoding) {
this._renderer.outputEncoding = this._encodingFromName(settings.outputEncoding);
}
if (settings.toneMapping) {
this._renderer.toneMapping = this._toneMappingFromName(settings.toneMapping);
}
if (typeof settings.toneMappingExposure === 'number') {
this._renderer.toneMappingExposure = settings.toneMappingExposure;
}
}
_encodingFromName(name) {
const map = {
linear: THREE.LinearEncoding,
sRGB: THREE.sRGBEncoding,
srgb: THREE.sRGBEncoding,
};
return map[name] || THREE.LinearEncoding;
}
_toneMappingFromName(name) {
const map = {
none: THREE.NoToneMapping,
linear: THREE.LinearToneMapping,
reinhard: THREE.ReinhardToneMapping,
cineon: THREE.CineonToneMapping,
ACESFilmic: THREE.ACESFilmicToneMapping,
acesFilmic: THREE.ACESFilmicToneMapping,
};
return map[name] || THREE.NoToneMapping;
}
_applyCamera(cameraConfig = {}) {
this.setCameraControlMode(cameraConfig.controlMode === undefined ? 'orbit' : cameraConfig.controlMode);
const fov = typeof cameraConfig.fov === 'number' ? cameraConfig.fov : this._camera.fov;
const near = typeof cameraConfig.near === 'number' ? cameraConfig.near : this._camera.near;
const far = typeof cameraConfig.far === 'number' ? cameraConfig.far : this._camera.far;
if (fov !== this._camera.fov || near !== this._camera.near || far !== this._camera.far) {
this._camera.fov = fov;
this._camera.near = near;
this._camera.far = far;
this._camera.updateProjectionMatrix();
}
if (Array.isArray(cameraConfig.lookAt)) {
this._target.fromArray(cameraConfig.lookAt);
}
if (Array.isArray(cameraConfig.position)) {
const p = new THREE.Vector3().fromArray(cameraConfig.position);
const offset = p.clone().sub(this._target);
const radius = offset.length();
if (radius > 0) {
this._orbit.radius = radius;
this._orbit.theta = Math.atan2(offset.x, offset.z);
this._orbit.phi = Math.acos(Math.max(-1, Math.min(1, offset.y / radius)));
}
}
if (cameraConfig.orbit) {
this._applyOrbit(cameraConfig.orbit);
}
this._updateCameraPose();
}
_applyOrbit(orbit = {}) {
if (typeof orbit.theta === 'number') this._orbit.theta = orbit.theta;
if (typeof orbit.phi === 'number') this._orbit.phi = orbit.phi;
if (typeof orbit.radius === 'number') this._orbit.radius = orbit.radius;
}
_panCamera(deltaX, deltaY) {
// Drag the scene like a map. Move both the eye and its target on XZ so
// orientation, elevation and orbit distance remain unchanged. A horizontal
// camera basis stays well-defined even at ground level or directly overhead.
const rect = this._getCanvasRect();
if (!(rect.width > 0 && rect.height > 0)) return;
const camera = this._camera;
const viewHeight = 2 * this._orbit.radius * Math.tan(camera.fov * Math.PI / 360) / camera.zoom;
const horizontal = -deltaX * viewHeight * camera.aspect / rect.width;
const forward = deltaY * viewHeight / rect.height;
const sin = Math.sin(this._orbit.theta);
const cos = Math.cos(this._orbit.theta);
this._target.x += cos * horizontal - sin * forward;
this._target.z += -sin * horizontal - cos * forward;
this._requestRender();
}
_updateCameraPose() {
const o = this._orbit;
const t = this._target;
this._camera.position.set(
t.x + o.radius * Math.sin(o.phi) * Math.sin(o.theta),
t.y + o.radius * Math.cos(o.phi),
t.z + o.radius * Math.sin(o.phi) * Math.cos(o.theta)
);
this._camera.lookAt(t);
this._camera.updateMatrixWorld(true);
}
_applyLights(lights) {
if (!Array.isArray(lights)) return;
this._clearManagedLights();
lights.forEach((item = {}) => {
const color = item.color || '#ffffff';
const intensity = item.intensity === undefined ? 1 : item.intensity;
let light = null;
if (item.type === 'ambient') {
light = new THREE.AmbientLight(color, intensity);
} else if (item.type === 'directional') {
light = new THREE.DirectionalLight(color, intensity);
const pos = item.position || item.direction;
if (Array.isArray(pos)) light.position.fromArray(pos);
} else if (item.type === 'point') {
light = new THREE.PointLight(color, intensity, item.distance || 0, item.decay || 1);
if (Array.isArray(item.position)) light.position.fromArray(item.position);
}
if (light) {
this._scene.add(light);
this._managedLights.push(light);
}
});
}
_clearManagedLights() {
this._managedLights.forEach(light => this._scene.remove(light));
this._managedLights = [];
}
_clearSceneContent(reason = 'scenechange') {
this._sceneRequest = (this._sceneRequest || 0) + 1;
this._sceneGeneration = (this._sceneGeneration || 0) + 1;
this._interaction.reset(reason);
this._clearDecalDebugHelper();
const hierarchy = this._getHierarchy();
hierarchy.remove(Array.from(hierarchy.records.keys()), reason);
this._modelNodes = Object.create(null);
this._currentModel = null;
this._pendingDecalConfig = null;
while (this._sceneRoot.children.length) {
this._sceneRoot.remove(this._sceneRoot.children[0]);
}
if (this._decal) {
this._decal.dispose();
this._decal = null;
}
}
_clearCurrentModel() {
if (!this._currentModel) return;
const ids = Object.keys(this._modelNodes);
const id = ids.find((modelId) => this._modelNodes[modelId] === this._currentModel);
if (id) {
this._clearModelById(id);
} else {
this._clearModelObject(this._currentModel);
}
this._refreshCurrentModel();
}
_clearModelById(id) {
this._getHierarchy().remove([id]);
}
_clearModelObject(object) {
if (this._decal) this._decal.removeFromModel(object);
if (object.parent) object.parent.remove(object);
this._disposeObject(object);
}
_refreshCurrentModel() {
const ids = Object.keys(this._modelNodes);
this._currentModel = ids.length ? this._modelNodes[ids[ids.length - 1]] : null;
}
_disposeObject(object) {
const resources = new Set();
traverseModel(object, obj => {
if (obj.geometry) resources.add(obj.geometry);
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => { if (m) resources.add(m); });
}
});
resources.forEach(resource => resource.dispose());
}
_centerAndScale(object) {
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const s = 2 / maxDim;
object.scale.setScalar(s);
object.position.sub(center.multiplyScalar(s));
}
}
_startLoop() {
this._clearStopTimer();
if (this._rafId) return; // 已在跑
const loop = () => {
if (this._disposed) return; // canvas 已销毁,停止
this._updateCameraPose();
this._renderFrame();
if (!this._disposed) this._rafId = this._platform.requestAnimationFrame(this._canvas, loop);
};
loop();
}
_renderFrame() {
this._interaction.beforeRender();
if (this._disposed) return;
// 只在场景渲染期间隐藏,保留拾取使用的可见性和宿主设置的 visible 值。
const hidden = [];
if (this._hierarchy) {
this._hierarchy.records.forEach(record => {
if (record.invisible && record.object) {
hidden.push([record.object, record.object.visible]);
record.object.visible = false;
}
});
}
try {
this._renderer.render(this._scene, this._camera);
} finally {
hidden.forEach(([object, visible]) => { object.visible = visible; });
}
this._interaction.renderHelpers(this._renderer, this._camera);
if (this._uiObjects && this._uiObjects.length) {
const autoClear = this._renderer.autoClear;
this._renderer.autoClear = false;
this._renderer.clearDepth();
this._renderer.render(this._uiScene, this._uiCamera);
this._renderer.autoClear = autoClear;
}
}
_stopLoop() {
this._clearStopTimer();
if (this._rafId && this._canvas) {
this._platform.cancelAnimationFrame(this._canvas, this._rafId);
}
this._rafId = null;
}
_requestRender() {
if (this._disposed) return;
this._startLoop();
if (this._disposed) return;
if (this._renderMode !== 'continuous') {
this._clearStopTimer();
this._stopTimer = setTimeout(() => {
this._stopTimer = null;
this._stopLoop();
}, 100);
}
}
_clearStopTimer() {
if (!this._stopTimer) return;
clearTimeout(this._stopTimer);
this._stopTimer = null;
}
_pinchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Load an equirectangular image as a PMREM environment map.
* Uses canvas.createImage() — the only DOM-like image API available in
* WeChat miniprogram WebGL canvas (no document.createElementNS).
*/
_loadEnvMap(url, showSkybox) {
this._fetchFile(url).then((localPath) => {
if (this._disposed) return;
const ext = localPath.split('.').pop().toLowerCase();
const mime = ext === 'png' ? 'image/png' : 'image/jpeg';
const tryLoad = (src, fallback) => {
const img = this._platform.createImage(this._canvas);
img.onload = () => {
if (this._disposed) return;
this._applyEnvImage(img, showSkybox);
};
img.onerror = () => {
if (fallback) {
console.warn('[ModelRenderer] envMap src failed, trying fallback:', fallback);
tryLoad(fallback, null);
} else {
console.error('[ModelRenderer] envMap all sources failed, path:', localPath);
}
};
img.src = src;
};
// Primary: base64 data URI. Fallback: raw local path (works on some devices).
this._readFileBase64(localPath).then((data) => {
if (this._disposed) return;
console.log('[ModelRenderer] envMap base64 length:', data && data.length, 'path:', localPath);
tryLoad(`data:${mime};base64,${data}`, localPath);
}).catch(() => {
console.warn('[ModelRenderer] envMap readFile failed, trying path directly');
tryLoad(localPath, null);
});
}).catch((err) => {
console.error('[ModelRenderer] envMap fetch failed:', err);
});
}
_applyEnvImage(img, showSkybox) {
const tex = new THREE.Texture(img);
tex.mapping = THREE.EquirectangularReflectionMapping;
tex.encoding = THREE.sRGBEncoding;
tex.needsUpdate = true;
if (this._envTexture) this._envTexture.dispose();
this._envTexture = tex;
this._scene.environment = tex;
if (showSkybox) this._scene.background = tex;
this._requestRender();
}
/**
* Download a file from CDN, cache the local path in wx.storage by default.
* @param {string} url
* @param {boolean} shouldCache
* @returns {Promise<string>} local file path
*/
_fetchFile(url, shouldCache = true) {
return this._platform.fetchFile(url, {
cacheKey: CACHE_PREFIX + url,
shouldCache,
url: shouldCache ? url : appendNoCacheParam(url),
});
}
_fetchArrayBuffer(url, shouldCache = true) {
return this._platform.fetchArrayBuffer(url, {
cacheKey: CACHE_PREFIX + url,
shouldCache,
url: shouldCache ? url : appendNoCacheParam(url),
});
}
_readFileBase64(path) {
if (!this._platform.readFileBase64) {
return Promise.reject(new Error('readFileBase64 is not supported by this platform'));
}
return this._platform.readFileBase64(path);
}
}
module.exports = ModelRenderer;