UNPKG

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