npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

prophetk-3d-viewer

v0.4.0

Published

A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.

Downloads

554

Readme

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.

中文交互与配置指南 · Changelog

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:

{
  "usingComponents": {
    "model-viewer": "prophetk-3d-viewer/wechat/model-viewer"
  }
}

Use it in WXML:

<model-viewer
  id="viewer"
  canvas-id="modelCanvas"
  bind:uievent="onViewerUiEvent"
/>

Call the component API:

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:

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:

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:

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:

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():

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:

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:

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:

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 '[]'.

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:

<model-viewer id="viewer" bind:modelevent="onModelEvent" />
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:

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:

viewer.canvas.addEventListener('uiEvent', (e) => {
  console.log(e.detail.event);
});

The container must have a stable width and height:

#viewer {
  width: 100%;
  height: 480px;
}

Core API

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:

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.

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:

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:

renderer.updateDecal(Math.PI / 2, 0.3, 1.2, 1);

For projector decals, either pass projector options to updateDecal(options) or use updateProjectorDecal(options):

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:

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:

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:

{
  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:

const viewer = this.selectComponent('#viewer');
viewer.setUiLabelText('status_label', 'Loading model...');

Web usage:

viewer.setUiLabelText('status_label', 'Scale X: 1.25');

WeChat UI Events

Use lowercase bind:uievent in WXML:

<model-viewer
  id="viewer"
  canvas-id="modelCanvas"
  bind:uievent="onViewerUiEvent"
/>

Then route by e.detail.event:

Page({
  onViewerUiEvent(e) {
    if (e.detail.event === 'event_btn_reload_model') {
      this.selectComponent('#viewer').loadScene();
    }
  }
});

Event detail:

{
  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():

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:

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

{
  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:

{
  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

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.