﻿# prophetk-3d-viewer

Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.

The package bundles the same Three.js r128 build for both platforms. It does not rely on the host project's Three.js version, which keeps material, lighting, GLTF parsing, and decal shader behavior consistent.

[中文交互与配置指南](https://app.unpkg.com/prophetk-3d-viewer@latest/files/docs/INTERACTION_GUIDE.zh-CN.md) · [Changelog](https://app.unpkg.com/prophetk-3d-viewer@latest/files/CHANGELOG.md)

## Features

- WeChat Mini Program component.
- Web viewer helper for browser projects.
- Built-in Three.js r128 and patched GLTFLoader.
- GLB/GLTF loading.
- Scene JSON loading.
- Orbit and fixed-height XZ camera pan controls with runtime switching.
- Selectable models and fixed-height XZ dragging (`isMovable`).
- Opt-in model click events (`clickEvent`), including fixed models.
- Invisible model hotspots (`invisible`) that preserve mesh-based picking and interaction.
- Multi-level model parenting with local transforms and runtime `setParent` / `SetParent`.
- Two-finger and Ctrl+wheel camera-distance zoom on both control modes.
- Scene-wide drag grid and depth-occluded selection bounds, independently configurable.
- JSON transform snapshots of all loaded models.
- Canvas-rendered button UI for Mini Program/Web overlays.
- Cylindrical decal projection on PBR materials.
- WeChat local file cache and packaged relative GLB paths.

## WeChat Mini Program

Install the npm package, run "构建 npm" in WeChat DevTools, then register the component:

```json
{
  "usingComponents": {
    "model-viewer": "prophetk-3d-viewer/wechat/model-viewer"
  }
}
```

Use it in WXML:

```xml
<model-viewer
  id="viewer"
  canvas-id="modelCanvas"
  bind:uievent="onViewerUiEvent"
/>
```

Call the component API:

```js
const viewer = this.selectComponent('#viewer');

await viewer.loadScene(sceneConfig);
await viewer.loadModel('https://cdn.example.com/model.glb');
viewer.updateDecal(Math.PI / 4, 0, 1, 1);
```

Handle canvas-rendered UI events with `bind:uievent`:

```js
Page({
  onViewerUiEvent(e) {
    const { event } = e.detail;
    if (event === 'event_btn_reload_model') {
      this.selectComponent('#viewer').loadScene();
    }
  }
});
```

Remote model, texture, and scene URLs must be added to the Mini Program legal domain allowlist.

Packaged GLB models can use paths relative to the Mini Program root, such as
`await viewer.loadModel('demoRes/bucket.glb')` (also supports `./demoRes/bucket.glb`).
The WeChat adapter reads these files directly without downloading or caching them.
The bundled default scene references the repository demo assets `demoRes/plane.glb`
and `demoRes/bucket.glb`. Demo assets are not included in the npm package. In an
installed project, provide your own scene config and model files, or copy these
assets to the same paths before calling `loadScene()` without arguments.

## Camera controls

Set `camera.controlMode` in a scene config to choose what a single-finger or
left-mouse drag on empty space (or a fixed model) does:

```js
await viewer.loadScene({
  camera: {
    controlMode: 'pan', // 'orbit' or 'pan'
    position: [0, 1.5, 3],
    lookAt: [0, 0, 0],
  },
  models: [{ id: 'bucket', url: 'demoRes/bucket.glb', isMovable: true }],
});

viewer.setCameraControlMode('orbit');
viewer.setCameraControlMode('pan');
console.log(viewer.getCameraControlMode()); // 'pan'
```

- `orbit` preserves the original behavior: rotate around the current `lookAt` point.
- `pan` translates both the camera and its look-at point on the world XZ plane,
  keeping camera height, viewing direction and distance unchanged during the drag.
  Dragging right moves the scene right (camera left); dragging up/down moves along
  the camera's horizontal heading. Speed follows the viewing distance, field of
  view and canvas size, and stays stable at horizontal/overhead views.
- Switching modes preserves the current camera pose. Switching back to `orbit`
  rotates around the translated target. A camera drag already in progress waits
  for all fingers to lift before the new mode takes effect.
- Model dragging, selection, UI controls, two-finger zoom and wheel zoom keep their
  existing behavior in both modes. The height constraint applies to pan dragging;
  zoom continues to change the camera's distance from its target.

On Web or the WeChat DevTools Android simulator, place the mouse over the canvas
and hold **Ctrl** while scrolling: **wheel up moves closer**, **wheel down moves
farther away**. Both `orbit` and `pan` support this. Wheel and two-finger zoom share
the same distance limits (1–30 world units) and leave the target, viewing direction
and model transforms unchanged. Ordinary wheel zoom is also supported. Web prevents
browser page zoom/scroll only over the canvas; Mini Program uses `catch:wheel`
and forwards `event.detail` (the DevTools event does not include DOM `ctrlKey`).
Wheel input is ignored during an active drag, UI or touch gesture to avoid jumps.
Custom input integrations can call `renderer.onWheel({ deltaY, deltaMode })`;
the Web and WeChat components bind this automatically.

The bundled default scene explicitly uses `controlMode: 'orbit'` in
`src/core/defaultSceneConfig.js`. Omitted modes also default to `orbit` for existing
scenes. Each `loadScene()` applies that scene's mode; runtime setters override it
until the next scene is loaded. Invalid modes throw a `RangeError`.

The same getters/setters are available on `ModelRenderer`, the Web viewer and the
WeChat component (`this.selectComponent('#viewer')`). The WeChat setter also queues
calls before renderer initialization. A setter called after `loadScene()` was
requested takes precedence even while the scene is still being read. The demo page
provides a switch button and updates its gesture hint to match the current mode.

## Model hierarchy

Starting in 0.3.0, `models[].parent` references another model's ID. Model order does
not matter, and hierarchies can have multiple levels:

```js
await viewer.loadScene({
  models: [
    { id: 'accessory', parent: 'body', url: 'accessory.glb',
      position: [0, 1, 0], rotation: [0, 90, 0], scale: [0.4, 0.4, 0.4],
      isMovable: false, clickEvent: 'openDetails' },
    { id: 'body', url: 'body.glb', isMovable: true },
  ],
});

viewer.setParent('accessory', null);   // Attach to the scene root.
viewer.SetParent('accessory', 'body'); // Equivalent alias.
```

Position, Euler XYZ rotation in degrees, and scale are local to the parent.
Omitting `parent` or setting it to `null` places a model at the scene root.
Both setter names are synchronous, return `void`, and preserve local transform
values, so the world pose may change. They work on the core, Web and WeChat APIs;
the WeChat component throws `renderer not ready` before initialization, so specify
`parent` in the load configuration when initializing a component.

A child can load before its parent. It remains hidden and cannot be picked until
all ancestors are ready. `loadModel()` resolves when that model's resource is
ready; it does not wait for a missing parent. `loadScene()` waits for its model
batch and UI/decal work, without waiting for parents outside that batch:

```js
await viewer.loadModel({ id: 'child', parent: 'parent', url: 'child.glb', normalize: false });
await viewer.loadModel({ id: 'parent', url: 'parent.glb', normalize: false });
```

Unknown child IDs, invalid parent values, self-parenting and cycles are rejected.
The setter also works while the child is loading. A failed model load can be
retried with the same ID; IDs already loaded or loading cannot be reused. Duplicate
IDs and cycles in a scene configuration are rejected before clearing the old scene.

Each model keeps its own `isMovable` and `clickEvent`; clicks do not bubble to parent
models. Dragging a parent moves its descendants. Dragging a child changes its local
position while preserving world Y; a non-invertible parent transform freezes position
updates. Bounds, decal materials and decal size calculations exclude independently
loaded child models. Reparenting cancels affected gestures with reason `parentchange`.

Removing a parent also removes its descendants, including pending loads.
`clearModel('missing-parent')` can remove children waiting for a parent that has not
been loaded. The return value counts removed model records. Clearing models,
replacing a scene or disposing the renderer cancels pending model loads; their
promises reject, and late resources are disposed.

`clearModel('value')` matches a model ID (or a missing parent ID) before trying a URL.
Use `clearModel({ url: 'asset.glb' })` to remove all models with exactly that URL,
even when another model has the same text as its ID. Use `{ id: 'body' }` to select
an ID explicitly. All forms apply the same descendant removal rule.

## Model selection and horizontal dragging

Set `isMovable: true` on an independently loaded model to enable selection and
dragging. It works with both `loadScene({ models: [...] })` and `loadModel()`:

```js
await viewer.loadModel('demoRes/bucket.glb', {
  id: 'bucket',
  isMovable: true,
  position: [0, 0, 0],
});
viewer.getSelectedModelId(); // 'bucket' after the user selects it, otherwise null
viewer.clearSelection();
```

Tap a movable model to select it, or press and drag it in one gesture. Movement
starts after 8 CSS pixels and preserves world-space height, rotation, scale and
the finger-to-model offset. Dragging empty space or a fixed model uses the selected
camera control mode (orbit by default); tapping it clears selection. Two fingers zoom. Adding a second finger
commits the current move and starts zooming; lift all fingers before dragging
again. UI controls retain priority. Mouse dragging uses the same rules.

The scene's `environment` switches independently control the selected model's bounds and drag grid:

```js
environment: {
  showDragGrid: true, // Horizontal grid while dragging; false hides it.
  showBounds: true,   // Selected model bounds; false hides them.
}
```

Both default to `true` when omitted, and are set explicitly in the default scene
config. They affect visualization only; selection, picking and movement still work
when either or both are disabled. Reloading a scene applies its own switches.
The grid renders over model geometry. Model bounds use normal depth testing and
are occluded by scene geometry that writes depth. Neither helper writes depth;
canvas buttons and labels remain above both helpers.

The selected model gets a light-blue box; a horizontal reference grid covers the
visible plane while dragging, including well beyond the model's starting position.
Its height stays at the starting model bottom and its lines are anchored to world
coordinates. Spacing follows the viewing scale, with major lines every five cells;
distant subpixel lines fade out toward the horizon. The grid uses two triangles
instead of creating thousands of line objects, and has a WebGL 1 fallback when
standard derivatives are unavailable. Touch picking samples an additional 12 CSS pixel ring
for small targets, while respecting geometry occlusion. Near-horizontal rays
(less than 10 degrees from the movement plane) stop movement at the last valid
position and show a hint to adjust the view. The camera is never adjusted
automatically. Touch cancellation or window/page blur restores the start position.

Use `onModelEvent` in the core/Web constructor, `bind:modelevent` in the WeChat
component, or the Web canvas's `modelevent` DOM event to observe interaction:

```js
const viewer = createWebModelViewer({
  container: '#viewer',
  onModelEvent(event) {
    if (event.type === 'dragend') {
      // Save event.position to application state if desired.
      console.log(event.modelId, event.position, event.reason);
    }
  },
});
```

- `click`: `modelId` and `event`, enabled by a non-empty `clickEvent` in model config.
- `selectionchange`: `modelId` (or `null`), `previousModelId`, and `reason`.
- `dragstart`, `drag`, `dragend`, `dragcancel`, `dragblocked`: `modelId`,
  `startPosition` and `position`, in parent-local coordinates matching model config.
- End reasons are `release` or `multitouch`; cancellation reasons include
  `touchcancel`, `blur`, `resize`, `selectioncleared`, `modelremoved`, `scenechange`, `dispose`, `parentchange`.
  `dragblocked` carries `reason: 'low-angle'` and a user-facing `message`.
- `drag` is coalesced to at most once per rendered frame. The final `dragend`
  contains the latest position even if no intermediate frame rendered. Events
  other than `drag` are immediate. A blocked gesture with no started movement
  emits `dragblocked` without `dragstart`/`dragend`.

### Model clicks

Set `clickEvent` independently of `isMovable`; fixed models can also emit clicks:

```js
await viewer.loadModel({
  id: 'accessory', url: 'accessory.glb', isMovable: false, clickEvent: 'openDetails',
});
// In onModelEvent (core/Web), or e.detail from bind:modelevent (WeChat):
function onModelEvent(detail) {
  if (detail.type === 'click' && detail.event === 'openDetails') {
    openDetails(detail.modelId);
  }
}
```

A click requires pressing and releasing on the same model within the gesture
movement threshold. Dragging, multitouch, cancellation, removal and UI input
suppress clicks. Omitting or leaving `clickEvent` blank disables clicks. Event names
are dispatched by your callback; the viewer does not invoke a same-named function
automatically. Web also emits the detail in a canvas `modelevent` DOM event.

### Export all model transforms

`getModelTransforms()` synchronously returns a **JSON string** containing an array
of `{ id, position, rotation, scale }` for every currently loaded model,
including fixed models and children waiting for missing parents. Child entries
also contain `parent`; scene-root entries omit it. It works on the core renderer, Web viewer and WeChat
component. An empty scene (or a WeChat component not initialized yet) returns `'[]'`.

```js
const json = viewer.getModelTransforms();
const transforms = JSON.parse(json);
// [{ id: 'bucket', position: [1, 0, 2], rotation: [0, 90, 0], scale: [1, 1, 1] }]

// Merge into your original config to preserve URLs and other model options.
const byId = new Map(transforms.map(item => [item.id, item]));
const savedScene = {
  ...sceneConfig,
  models: sceneConfig.models.map(item => {
    const transform = byId.get(item.id);
    if (!transform) return item;
    // Clear the original parent when the snapshot describes a scene-root model.
    return { ...item, ...transform, parent: transform.parent === undefined ? null : transform.parent };
  }),
};
```

Positions are current parent-local coordinates. Rotation is Euler XYZ in **degrees**
(converted from the current quaternion), and scale is always `[x, y, z]`. These are
the same units accepted by model config; Euler values may differ numerically from
the original input while representing the same orientation. Snapshots include live
drag positions, do not mutate models/config and exclude cameras, lights, UI,
helpers and individual GLB submeshes. Use unique explicit model IDs to merge or
persist snapshots; newly appended models also need their original URL/config saved.
Read in `dragend` for committed positions. No storage/network writes occur automatically.

WeChat event and snapshot example:

```xml
<model-viewer id="viewer" bind:modelevent="onModelEvent" />
```

```js
Page({
  onModelEvent(e) {
    if (e.detail.type === 'dragend') {
      const json = this.selectComponent('#viewer').getModelTransforms();
      wx.setStorageSync('model-transforms', json);
    }
  },
});
```

Selection does not change the decal target. Projector decals retain their
existing world-space behavior. This feature operates on static GLB roots, not
individual nodes or animated/deforming meshes, and does not implement collision,
snapping, movement boundaries, undo history or automatic persistence. Input config is not mutated.

## Web

Use the bundled Web entry:

```js
const { createWebModelViewer } = require('prophetk-3d-viewer/web');

const viewer = createWebModelViewer({
  container: document.querySelector('#viewer'),
  bgColor: '#000000',
  onUiEvent(detail) {
    console.log(detail.event);
  },
});

await viewer.loadScene(sceneConfig);
```

The Web helper also dispatches a `uiEvent` DOM event on the canvas:

```js
viewer.canvas.addEventListener('uiEvent', (e) => {
  console.log(e.detail.event);
});
```

The container must have a stable width and height:

```css
#viewer {
  width: 100%;
  height: 480px;
}
```

## Core API

```js
const { ModelRenderer, WebPlatformAdapter } = require('prophetk-3d-viewer');

const renderer = new ModelRenderer(canvas, {
  platform: new WebPlatformAdapter(),
  renderMode: 'continuous',
});
```

Main methods:

- `loadModel(url, options)`
- `switchModel(url, options)`
- `loadScene(configOrUrl)`
- `clearModel(target)`
- `clearAllModels()`
- `setParent(modelId, parentId)` / `SetParent(modelId, parentId)` → preserve local transforms; `null` selects the scene root
- `getModelTransforms()` → JSON string of all loaded models, including child parent IDs
- `getSelectedModelId()` → selected ID or `null`
- `clearSelection()`
- `getCameraControlMode()` / `setCameraControlMode('orbit' | 'pan')`
- `onWheel({ deltaY, deltaMode, ctrlKey })` → whether camera distance changed (core input API)
- `applyDecal(url, options)`
- `updateDecal(options)` or `updateDecal(angle, height, scaleX, scaleY)`
- `updateProjectorDecal(options)`
- `removeDecal()`
- `setUiEventHandler(handler)`
- `setUiLabelText(id, text)`
- `dispose()`

## Decal API

`applyDecal(url, options)` loads a decal texture and applies it to the current model. PNG textures with alpha are recommended.

Cylindrical decals wrap around the model on the world Y axis:

```js
await renderer.applyDecal('https://cdn.example.com/decal.png', {
  projectionMode: 'cylindrical',
  angle: Math.PI / 4,
  height: 0,
  scaleX: 1,
  scaleY: 1,
  arcWidth: Math.PI / 3,
  heightRange: 1,
  opacity: 1
});
```

Projector decals use an orthographic projector. By default, the decal texture keeps
its original aspect ratio inside the projector area; `scaleX: 1` and `scaleY: 1`
mean the texture's natural ratio, not stretching to fill the projector rectangle.

```js
await renderer.applyDecal('https://cdn.example.com/decal.png', {
  projectionMode: 'projector',
  projectorPosition: [0, 0.5, 2],
  projectorTarget: [0, 0.5, 0],
  projectorRollDeg: 0,
  projectorWidth: 1,
  projectorHeight: 1,
  projectorNormalThreshold: 0,
  scaleX: 1,
  scaleY: 1,
  opacity: 1
});
```

`projectionMode` can be `cylindrical`, `projector`, or `orthographic`. `mode` is also accepted as an alias. Projector options automatically switch the decal to projector mode when `projectionMode` is omitted.

Common decal options:

- `scaleX`, `scaleY`: texture scale. In projector mode, `1` keeps the decal texture's original aspect ratio inside the projector area.
- `opacity`: decal blend opacity.
- `angle`: cylindrical center angle in radians.
- `height`: cylindrical center height in world units.
- `arcWidth`: cylindrical angular width in radians.
- `heightRange`: cylindrical vertical span in world units.
- `projectorPosition` or `position`: projector origin.
- `projectorTarget` or `targetPoint`: world point the projector aims at.
- `projectorDirection` or `direction`: projector direction when no target is provided.
- `projectorRotation` or `rotation`: Euler XYZ rotation in radians when no target is provided.
- `projectorRoll` or `roll`: rotation around the projector direction in radians.
- `projectorRollDeg` or `rollDeg`: rotation around the projector direction in degrees.
- `projectorUp` or `up`: projector up direction.
- `projectorWidth` or `width`: projector width.
- `projectorHeight`: projector height. `height` is also accepted in projector mode, but `projectorHeight` avoids ambiguity with cylindrical height.
- `projectorNormalThreshold` or `normalThreshold`: surface normal visibility threshold.

`updateDecal(options)` updates the active decal without reloading the texture:

```js
renderer.updateDecal({
  angle: Math.PI / 2,
  height: 0.3,
  scaleX: 1.2,
  scaleY: 1,
  arcWidth: Math.PI / 4,
  heightRange: 0.8,
  opacity: 0.9
});
```

The legacy positional form is still supported for cylindrical decals:

```js
renderer.updateDecal(Math.PI / 2, 0.3, 1.2, 1);
```

For projector decals, either pass projector options to `updateDecal(options)` or use `updateProjectorDecal(options)`:

```js
renderer.updateProjectorDecal({
  projectorPosition: [0, 0.5, 2],
  projectorTarget: [0, 0.5, 0],
  projectorRollDeg: 0,
  projectorWidth: 1,
  projectorHeight: 1
});
```

## Canvas UI

The `ui` field in `loadScene()` renders interactive controls inside the same Three.js canvas. This is useful on WeChat Mini Program because ordinary Mini Program views and `cover-view` are not reliable overlays above a WebGL canvas.

Currently supported UI types:

- `button`
- `slider`
- `label`

Example:

```js
await viewer.loadScene({
  models: [
    {
      id: 'main',
      type: 'gltf',
      url: 'https://cdn.example.com/model.glb'
    }
  ],
  ui: [
    {
      type: 'button',
      position: [0.04, 0.04],
      scale: [0.24, 0.08],
      text: 'Reload',
      img: '',
      alpha: 1,
      event: 'event_btn_reload_model'
    },
    {
      type: 'slider',
      position: [0.04, 0.16],
      scale: [0.42, 0.08],
      text: 'decal x',
      min: 0.2,
      max: 3,
      value: 1,
      step: 0.01,
      event: 'event_slider_decal_scale_x',
      target: 'decal.scaleX'
    },
    {
      type: 'label',
      id: 'status_label',
      position: [0.04, 0.88],
      scale: [0.5, 0.06],
      text: 'Ready',
      color: '#ffffff',
      backgroundColor: 'rgba(0, 0, 0, 0.35)'
    }
  ]
});
```

`position` and `scale` use normalized canvas coordinates. The origin is the canvas bottom-left corner:

- `position: [0, 0]` places the button at the bottom-left of the canvas.
- `position: [1, 1]` starts at the top-right of the canvas.
- X grows to the right.
- Y grows upward.
- `position` means the button's bottom-left corner, not its center.
- `scale: [0.24, 0.08]` means 24% of the canvas width and 8% of the canvas height.
- Normalized values keep button size and placement broadly consistent across different phones and canvas resolutions.

Button fields:

- `type`: must be `'button'`.
- `position`: `[x, y]` normalized to the canvas, bottom-left origin.
- `scale`: `[width, height]` normalized to the canvas.
- `text`: optional text drawn into the button texture.
- `img`: optional image URL used as the button background. If empty, a default rounded dark button is drawn.
- `alpha`: button opacity from `0` to `1`.
- `event`: string key emitted when the user taps/clicks the button.
- `backgroundColor`: optional fallback background color when `img` is empty.
- `borderColor`: optional fallback border color when `img` is empty.
- `color`: optional text color.
- `fontSize`: optional text size in pixels.

When the user presses a button, it renders a built-in pressed state by scaling down slightly and lowering opacity. If the pointer moves outside the button before release, the pressed state is cancelled and no event is emitted.

Slider fields:

- `type`: `'slider'`. The alias `'slide'` is also accepted.
- `position`: `[x, y]` normalized to the canvas, bottom-left origin.
- `scale`: `[width, height]` normalized to the canvas.
- `text`: optional label shown above the track.
- `min`: minimum value. Default is `0`.
- `max`: maximum value. Default is `1`.
- `value`: initial value.
- `step`: optional step size.
- `alpha`: slider opacity from `0` to `1`.
- `event`: string key emitted when the slider changes.
- `target`: optional built-in target. Currently `decal.scaleX` and `decal.scaleY` update the active decal directly.
- `trackColor`: optional track color.
- `fillColor`: optional filled track color.
- `thumbColor`: optional thumb color.
- `thumbBorderColor`: optional thumb border color.
- `color`: optional label text color.
- `fontSize`: optional label text size in pixels.

Example decal scale controls:

```js
ui: [
  {
    type: 'slider',
    position: [0.04, 0.16],
    scale: [0.42, 0.08],
    text: 'decal x',
    min: 0.2,
    max: 3,
    value: 1,
    step: 0.01,
    event: 'event_slider_decal_scale_x',
    target: 'decal.scaleX'
  },
  {
    type: 'slider',
    position: [0.04, 0.27],
    scale: [0.42, 0.08],
    text: 'decal y',
    min: 0.2,
    max: 3,
    value: 1,
    step: 0.01,
    event: 'event_slider_decal_scale_y',
    target: 'decal.scaleY'
  }
]
```

Slider events include the current value:

```js
{
  type: 'slider',
  event: 'event_slider_decal_scale_x',
  text: 'decal x',
  value: 1.35
}
```

Label fields:

- `type`: must be `'label'`.
- `id`: required if the label text will be updated from code.
- `position`: `[x, y]` normalized to the canvas, bottom-left origin.
- `scale`: `[width, height]` normalized to the canvas.
- `text`: initial display text.
- `alpha`: label opacity from `0` to `1`.
- `backgroundColor`: optional label background.
- `borderColor`: optional border color.
- `borderWidth`: optional border width in pixels.
- `radius`: optional corner radius in pixels.
- `padding`: optional horizontal text padding in pixels.
- `color`: optional text color.
- `fontSize`: optional text size in pixels.
- `align`: optional text alignment, one of `'left'`, `'center'`, or `'right'`.

Labels do not consume touch input. They are rendered in the canvas like buttons and sliders, but pointer/touch events pass through to model selection/dragging or the active camera controls.

Update a label after the scene is loaded:

```js
const viewer = this.selectComponent('#viewer');
viewer.setUiLabelText('status_label', 'Loading model...');
```

Web usage:

```js
viewer.setUiLabelText('status_label', 'Scale X: 1.25');
```

### WeChat UI Events

Use lowercase `bind:uievent` in WXML:

```xml
<model-viewer
  id="viewer"
  canvas-id="modelCanvas"
  bind:uievent="onViewerUiEvent"
/>
```

Then route by `e.detail.event`:

```js
Page({
  onViewerUiEvent(e) {
    if (e.detail.event === 'event_btn_reload_model') {
      this.selectComponent('#viewer').loadScene();
    }
  }
});
```

Event detail:

```js
{
  type: 'button',
  event: 'event_btn_reload_model',
  text: 'Reload',
  x: 24,
  y: 438
}
```

`x` and `y` are the raw touch position in canvas CSS pixels with top-left browser/touch coordinates. Use `event` as the stable business key.

### Web UI Events

For Web projects, pass `onUiEvent` to `createWebModelViewer()`:

```js
const viewer = createWebModelViewer({
  container: document.querySelector('#viewer'),
  onUiEvent(detail) {
    if (detail.event === 'event_btn_reload_model') {
      viewer.loadScene(sceneConfig);
    }
  }
});
```

The canvas also emits a `uiEvent` DOM `CustomEvent`:

```js
viewer.canvas.addEventListener('uiEvent', (e) => {
  console.log(e.detail);
});
```

### UI Notes

- UI is rendered after the 3D scene with a separate orthographic camera.
- UI config uses normalized coordinates, then the renderer converts them to current canvas CSS pixels.
- When the Web helper resizes, the active UI config is rebuilt against the new canvas size.
- UI does not use Mini Program layout, so it will not be affected by `z-index` or native component layer limits.
- UI textures are generated with an offscreen 2D canvas. On WeChat, this requires `wx.createOffscreenCanvas`.
- Image URLs in `img` use the same download/cache path as model and decal assets, so remote domains must be allowlisted.
- Buttons and sliders consume touch input while active, so UI interaction will not rotate the model. Labels are non-interactive.

## Scene Config

```js
{
  renderer: {
    clearColor: '#000000',
    clearAlpha: 1,
    outputEncoding: 'sRGB',
    toneMapping: 'ACESFilmic'
  },
  environment: {
    url: '',
    showSkybox: false,
    showDragGrid: true,
    showBounds: true
  },
  camera: {
    controlMode: 'orbit',
    position: [0, 1.5, 3],
    lookAt: [0, 0, 0],
    fov: 60
  },
  models: [
    {
      id: 'main',
      type: 'gltf',
      url: 'https://cdn.example.com/model.glb',
      isMovable: true,
      rotation: [0, 90, 0],
      scale: [1, 1, 1]
    }
  ],
  lights: [
    { type: 'ambient', color: '#ffffff', intensity: 0.5 },
    { type: 'directional', color: '#ffffff', intensity: 0.8, position: [5, 10, 7] }
  ],
  ui: [
    {
      type: 'button',
      position: [0.04, 0.04],
      scale: [0.24, 0.08],
      text: 'Reload',
      img: '',
      alpha: 1,
      event: 'event_btn_reload_model'
    },
    {
      type: 'slider',
      position: [0.04, 0.16],
      scale: [0.42, 0.08],
      text: 'decal x',
      min: 0.2,
      max: 3,
      value: 1,
      step: 0.01,
      event: 'event_slider_decal_scale_x',
      target: 'decal.scaleX'
    },
    {
      type: 'slider',
      position: [0.04, 0.27],
      scale: [0.42, 0.08],
      text: 'decal y',
      min: 0.2,
      max: 3,
      value: 1,
      step: 0.01,
      event: 'event_slider_decal_scale_y',
      target: 'decal.scaleY'
    },
    {
      type: 'label',
      id: 'status_label',
      position: [0.04, 0.88],
      scale: [0.5, 0.06],
      text: 'Ready',
      color: '#ffffff',
      backgroundColor: 'rgba(0, 0, 0, 0.35)'
    }
  ],
  decal_projector: {
    target: 'main',
    url: 'https://cdn.example.com/decal.png',
    scaleX: 1,
    scaleY: 1,
    opacity: 1,
    projectorPosition: [0, 0.5, 2],
    projectorTarget: [0, 0.5, 0],
    projectorRollDeg: 0,
    projectorWidth: 1,
    projectorHeight: 1,
    projectorNormalThreshold: 0,
    debug: true
  }
}
```

For cylindrical decals, use the `decal` block:

```js
{
  decal: {
    target: 'main',
    url: 'https://cdn.example.com/decal.png',
    decalAngleDeg: 45,
    decalHeight: 0,
    decalScaleX: 1,
    decalScaleY: 1,
    arcWidth: Math.PI / 3,
    heightRange: 1,
    opacity: 1
  }
}
```

For projector decals, use the top-level `decal_projector` block. `decalProjector` is also accepted. In both blocks, `target` is the model id; projector aim uses `projectorTarget`.

Model `rotation` values are Euler XYZ degrees.
Model `parent` is an optional model ID; position, rotation and scale are relative
to that parent. `clickEvent` is an optional event name, independent of `isMovable`.
Starting in 0.4.0, set `invisible: true` to hide a model and its descendants from rendering while
preserving mesh-based picking and interaction. Clicks still require `clickEvent`,
and selection/dragging require `isMovable: true`. Invisible models still occlude
other models during picking; bounds and drag grids keep their existing switches.

## Local Development

```bash
npm run check
```

The checks verify bundled Three.js r128 across all entries, model clicks and gestures,
parenting and transform export, load cancellation and descendant cleanup, URL-based
removal, metadata-safe traversal, decal size isolation, camera controls and wheel zoom,
grid coverage and depth behavior, environment switches, and Web/WeChat input adapters. For the repository
demo assets, additionally run `node scripts/check-wechat-local-files.js`.
