prophetk-3d-viewer
v0.4.0
Published
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
Downloads
554
Maintainers
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.
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'orbitpreserves the original behavior: rotate around the currentlookAtpoint.pantranslates 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
orbitrotates 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:modelIdandevent, enabled by a non-emptyclickEventin model config.selectionchange:modelId(ornull),previousModelId, andreason.dragstart,drag,dragend,dragcancel,dragblocked:modelId,startPositionandposition, in parent-local coordinates matching model config.- End reasons are
releaseormultitouch; cancellation reasons includetouchcancel,blur,resize,selectioncleared,modelremoved,scenechange,dispose,parentchange.dragblockedcarriesreason: 'low-angle'and a user-facingmessage. dragis coalesced to at most once per rendered frame. The finaldragendcontains the latest position even if no intermediate frame rendered. Events other thandragare immediate. A blocked gesture with no started movement emitsdragblockedwithoutdragstart/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;nullselects the scene rootgetModelTransforms()→ JSON string of all loaded models, including child parent IDsgetSelectedModelId()→ selected ID ornullclearSelection()getCameraControlMode()/setCameraControlMode('orbit' | 'pan')onWheel({ deltaY, deltaMode, ctrlKey })→ whether camera distance changed (core input API)applyDecal(url, options)updateDecal(options)orupdateDecal(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,1keeps 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.projectorPositionorposition: projector origin.projectorTargetortargetPoint: world point the projector aims at.projectorDirectionordirection: projector direction when no target is provided.projectorRotationorrotation: Euler XYZ rotation in radians when no target is provided.projectorRollorroll: rotation around the projector direction in radians.projectorRollDegorrollDeg: rotation around the projector direction in degrees.projectorUporup: projector up direction.projectorWidthorwidth: projector width.projectorHeight: projector height.heightis also accepted in projector mode, butprojectorHeightavoids ambiguity with cylindrical height.projectorNormalThresholdornormalThreshold: 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:
buttonsliderlabel
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.
positionmeans 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 from0to1.event: string key emitted when the user taps/clicks the button.backgroundColor: optional fallback background color whenimgis empty.borderColor: optional fallback border color whenimgis 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 is0.max: maximum value. Default is1.value: initial value.step: optional step size.alpha: slider opacity from0to1.event: string key emitted when the slider changes.target: optional built-in target. Currentlydecal.scaleXanddecal.scaleYupdate 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 from0to1.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-indexor native component layer limits. - UI textures are generated with an offscreen 2D canvas. On WeChat, this requires
wx.createOffscreenCanvas. - Image URLs in
imguse 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 checkThe 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.
