3D Viewer
Renders equipment in 3D, driven live by tags — a 3D equivalent of a P&ID mimic. From 1.0.8 it is also a digital twin: it shows a 3D symbol or a whole plant of them, and widgets placed inside it can ride on the parts they describe.


Reach for it when you need:
- A 3D plant or machine view where components colour, move and fill by state
- A digital twin: a faceplate pinned to each pump, following it as the operator orbits
- A fly-to list — pick a vessel from a dropdown and the camera frames it
Component name: Viewer3D · Category: Display
In the palette: Renders a 3D symbol from the Symbol Library (symbol3d, driven by its params — bind each to a tag; the symbol's rules colour, move and label its parts), a plant of them (equipment), or a plain .glb model (model; to drive a .glb from tags, make it a 3D symbol: Symbol Library → Build from model). It is also a container (a digital twin): widgets placed inside it overlay the 3D view. Each child's anchor prop places it — { mode: "screen" } keeps it at its x/y like a HUD panel; { mode: "node", target: "<part name>", point: "top"|"center"|"bottom", align: "bottom"|"top"|"center"|"left"|"right", offsetX, offsetY, occlude, hideBeyond } pins it to a model part so it follows the part as the camera moves (e.g. an EmbeddedView faceplate on a pump).
Properties
Any property can be bound to a tag, another component's property, or a query.
| Property | Type | Default | Description |
|---|---|---|---|
model | asset | "" | The 3D model (.glb), from the asset Library or the Uploads folder. Bindable — bind it to a tag/expression yielding a path (e.g. Models/Pumps/p1.glb) or an upload:// ref (e.g. a File Manager widget's {{ <name>.selectedItem.ref }}) to swap the model at runtime. |
symbol3d | string | "" | A 3D symbol from the Symbol Library, as "user:<id>" (a kind "3d" symbol's symbolKey). When set, the viewer shows that equipment instead of 'model', driven by 'params'. 1.0.8 |
params | object | {} | Inputs of this 3D symbol (defined in the Symbol Library). Bind each to a tag to drive the model. 1.0.8 |
equipment | object | {} | The digital-twin plant: 3D symbols placed in this viewer, by id — {"TK101": {"symbol3d": "user:<id>", "params": {…}, "position": [x, y, z], "rotation": [0, 90, 0], "scale": 1}}. They stand on 'model' (a building / site GLB) when one is set. Each item's params bind to tags like a single symbol's. Its parts are addressed "<id>/<part>" (e.g. "TK101/Shell") by the part picker, camera.focus and widgets pinned in the viewer. Drop a 3D symbol from the Symbol Library onto the viewer to add one. Ignored while 'symbol3d' is set. 1.0.8 |
showLabels | boolean | true | Show the labels the symbol's Label (text) rules put on its parts — each a value with its name. Off hides them in this viewer only; to remove one for every copy, delete its rule in the 3D Symbol Editor (right-click → Edit 3D Symbol). 1.0.8 |
fitOnLoad | boolean | true | Frame the model automatically when no camera has been set. |
background | color | "" | Canvas background color. Blank = transparent (the view shows through). |
lighting | object | {"preset":"studio","intensity":1,"ambient":1,"direction":[5,… (truncated) | Light rig, how strong it is, and where the key light comes from. Set the direction with the edit-mode 'Set light' tool (aim the view, then Save) or type it. Every field is bindable. |
↳ lighting.preset | enum | studio | Built-in light rig (self-hosted — no external environment downloads). Options: studio · basic · none. |
↳ lighting.intensity | number | 1 | Brightness of the main/key light. 1 = the preset's own value; raise it for a model that reads too dark. (three.js has no hard max — this is just the slider range; bind a tag for any value.) |
↳ lighting.ambient | number | 1 | Fill/ambient light — lifts the shadowed side. 1 = the preset value; 0 = only the key light (hard, high-contrast). |
↳ lighting.direction | array | [5,10,7] | Where the key light shines FROM, as [x, y, z] in model space — the sun's position, not a rotation. [5, 10, 7] is over the model's right shoulder. Unlike the camera this HAS a real default, so its three slots arrive filled. Use the edit-mode 'Set light' tool to drag it into place instead of typing it. |
interactive | boolean | true | Allow orbit/pan/zoom in LiveView (the editor always locks the camera; use Set camera to frame it). |
camera | object | {} | Input: the AUTHORED default camera pose — where the camera sits (position), what it looks at (target) and how wide the lens is (fov). It is EMPTY by default, which means 'frame the model automatically'; the moment a position is filled in, that framing replaces the auto-fit. Easiest way to set it: enter the widget, click 'Set camera', aim the view, Save — that writes every field at once. (For the LIVE pose an operator orbits to at runtime, read the output.cameraView output instead.) |
↳ camera.focus | modelNode | — | The PART to look at: the camera flies to it and frames it, keeping the direction it is looking from. Pick it from the model, or BIND it — to a tag, a client variable that buttons set, or a list or dropdown's selected value — and every change flies to the new part: that is a fly-to list. A plant item's part is 'TK101/Shell'. Empty = the normal view (the position/target below, or the automatic framing); clearing it flies back there. 1.0.8 |
↳ camera.position | array | — | Where the CAMERA sits — three numbers [x, y, z] in the model's own units (Y is up). Not an offset: it is an absolute point in the model's space, so [4, 3, 6] means 4 right, 3 up, 6 towards the viewer from the model origin. Leave it EMPTY to keep the automatic framing (fitOnLoad) — filling it in is exactly what turns the auto-fit off. Easiest way to set it: enter the widget, aim the view with 'Set camera', Save. |
↳ camera.target | array | — | The point the camera LOOKS AT — [x, y, z] in model units (three's lookAt point). It is also the pivot an operator's orbit spins around and the centre panning moves, so put it on the equipment you care about. EMPTY means wherever the automatic framing aims, which is the model's centre; [0, 0, 0] is the model ORIGIN, and for an off-centre model those are not the same place. Change it at runtime (an event action, a bound tag) and the camera FLIES to the new point rather than cutting, unless orbit.smooth is off. |
↳ camera.fov | number | — | Field of view — how WIDE the lens is, as the vertical angle it covers, in degrees. It changes perspective, not position: a narrow angle flattens the model and shows less of the scene from the same spot, a wide one exaggerates depth and fits more in. Empty keeps the scene's own 45° normal lens; 20-30 reads flat and schematic (kind to equipment drawings); 80-120 is a dramatic wide angle that bends straight pipe runs. Ignored when look.projection is orthographic (that view has no perspective to widen). |
↳ camera.zoom | number | — | Magnification for an ORTHOGRAPHIC view only (look.projection = orthographic), where fov has no meaning — higher fills more of the box. Empty (or 0) = fit the model automatically, which is almost always what you want; 'Set camera' fills in the real value when you frame one by hand. 1.0.6 |
orbit | object | {"minDistance":0,"maxDistance":0,"enableZoom":true,"enablePa… (truncated) | How the camera may be moved and how that movement FEELS: which gestures operators get (zoom / pan / rotate), how far they may zoom, how smooth and how fast each gesture is, and auto-rotate. |
↳ orbit.minDistance | number | 0 | Closest the operator may zoom, in model units. 0 = no limit. |
↳ orbit.maxDistance | number | 0 | Furthest the operator may zoom out. 0 = no limit — the usual cause of 'the model vanished'. |
↳ orbit.enableZoom | boolean | true | Allow zooming in LiveView. |
↳ orbit.enablePan | boolean | true | Allow panning in LiveView (right-drag / two-finger drag). Off keeps the model centred no matter what an operator drags. |
↳ orbit.enableRotate | boolean | true | Allow orbiting in LiveView (left-drag). Off pins the viewing angle — a fixed overview that an operator cannot tumble into a useless view. 1.0.6 |
↳ orbit.smooth | boolean | true | Ease every camera move. Covers both what an OPERATOR does — rotate, pan and zoom glide to a stop instead of snapping — and what the VIEW does: a new camera pose (a re-aimed target/lookAt from an event, a bound tag, or an edit here) flies there instead of cutting, so nobody loses track of which way round the equipment is. Any gesture cancels a flight in progress. Costs a handful of extra frames per move; the canvas is idle in between either way. 1.0.6 |
↳ orbit.smoothness | number | 0.5 | How long a glide or flight lasts: 0 is almost instant, 1 is a long floaty coast. 0.5 (about 300 ms) suits a control screen; raise it for a lobby display. 1.0.6 |
↳ orbit.rotateSpeed | number | 1 | How far the model turns per pixel dragged. Below 1 is finer control for close inspection; above 1 spins faster. 1.0.6 |
↳ orbit.panSpeed | number | 1 | How far the view slides per pixel dragged. 1.0.6 |
↳ orbit.zoomSpeed | number | 1 | How much one wheel notch (or pinch) moves in or out. Lower it on a big plant model where a single notch overshoots. 1.0.6 |
↳ orbit.autoRotate | boolean | false | Slowly orbit the model — a lobby/overview display. It draws continuously, so leave it off on control screens. |
↳ orbit.autoRotateSpeed | number | 2 | Auto-rotate speed (negative reverses). |
look | object | {"projection":"perspective","toneMapping":"none","exposure":… (truncated) | How the model READS on screen: projection, tone mapping + exposure, shadows/ground/grid/axes, selection highlight, and the device-pixel-ratio cap. |
↳ look.projection | enum | perspective | Perspective for a realistic view; orthographic for schematic/isometric layouts where equal sizes must read as equal. Options: perspective · orthographic. |
↳ look.toneMapping | enum | none | None keeps authored colours exactly (the HMI-faithful default). ACES/Neutral are filmic curves that dim saturated colour — pick them only for a model lit for it. Options: none · aces · neutral. |
↳ look.exposure | number | 1 | Renderer exposure. 1 = unchanged; raise it for a model that reads too dark. |
↳ look.environment | enum | auto | What the scene reflects and is softly lit by — built in, nothing is downloaded. Auto: studio for 3D symbols and plants, none for a GLB model (it keeps the look it was made with). Studio: a soft light box. Outdoor: open sky with the sun. Plant floor: a hall under rows of overhead lights. Options: auto · studio · outdoor · plant · none. 1.0.8 |
↳ look.shadows | boolean | false | Cast/receive shadows. Adds a shadow-map pass per frame — worth it for depth cues on a hero model, not for a wall of small ones. |
↳ look.ground | boolean | false | Shadow-catching ground plane at the model's base (invisible except where a shadow falls). |
↳ look.grid | boolean | false | Reference grid — spatial orientation while authoring. |
↳ look.axes | boolean | false | X/Y/Z axis helper at the origin — handy when typing transform offsets. |
↳ look.highlightSelection | boolean | true | Draw a box around the clicked node, so a selection is visible and not only published to selectedObject. |
↳ look.highlightColor | color | #38bdf8 | Selection box colour. |
↳ look.maxDpr | number | 2 | Device-pixel-ratio ceiling — the cheapest quality/performance dial. Lower it on 4K panels and thin clients. |
animation | object | {"clip":"","playing":false,"speed":1,"time":0,"loop":true} | Input: animation playback CONTROL — clip name, play/pause, speed, scrub time and loop. Every field is bindable. (For the list of clip names this model offers, see the output.animationClips output.) |
↳ animation.clip | string | "" | Name of the clip to drive (see the output.animationClips output). Blank = no animation. |
↳ animation.playing | boolean | false | Play/pause. Bind it to a running/stopped tag and the model animates with the equipment. |
↳ animation.speed | number | 1 | Playback rate (negative plays backwards). Bind a speed tag for a motion-proportional indicator. |
↳ animation.time | number | 0 | Scrub position in seconds, honoured while PAUSED — bind a process value here to use the clip as a position indicator (e.g. valve travel). |
↳ animation.loop | boolean | true | Repeat the clip. Off holds the last frame when it finishes. |
output | object | {"selectedObject":{},"cameraView":{},"sceneNodes":[],"animat… (truncated) | Read-only runtime outputs the widget publishes — bind a leaf, e.g. {{ <Viewer3D>.output.selectedObject.name }}. Leaves: selectedObject { name, path, label }, cameraView { position, target, fov }, sceneNodes [{ name, path, label }], animationClips [string]. 'label' is the part's label from its 3D symbol, or its name when it has none. |
↳ output.selectedObject | object | {} | The node last clicked ({ name, path, label }) — 'label' is the part's label from its 3D symbol, or its name when it has none; show {{ <Viewer3D>.output.selectedObject.label }}, address by .name. |
↳ output.cameraView | object | {} | The LIVE camera pose { position, target, fov }, updated when an operator's orbit/pan gesture ends. This is the read-back counterpart of the 'camera' input (which is the AUTHORED default pose you set) — bind {{ <Viewer3D>.output.cameraView.position }} to mirror the current view to a tag or another viewer. |
↳ output.sceneNodes | array | [] | The model's node list [{ name, path, label }] (first 200) — the part addresses available in this model, each with its label (or its name when it has none). |
↳ output.animationClips | array | [] | The animation clip NAMES discovered in the loaded model — the reference list for what animation.clip accepts (distinct from the animation input, which is the playback CONTROL: clip/play/speed/time/loop). |
style | style | {} | Custom CSS properties |
Events
Attach event actions to these in the Event Manager.
| Event | Label | Group | Payload |
|---|---|---|---|
click | Click | Mouse | The DOM event. |
dblclick | Double click | Mouse | The DOM event. |
contextmenu | Right click | Mouse | The DOM event. |
mouseenter | Mouse enter | Mouse | The DOM event. |
mouseleave | Mouse leave | Mouse | The DOM event. |
objectClick | Object click | 3D Object | The picked model node, under selectedObject ({ name, path }). |
objectDoubleClick | Object double click | 3D Object | The picked model node, under selectedObject. |
contextmenu fires your configured actions at runtime only — in LiveView and in preview. On the Designer canvas the right-click is captured to open the Event Manager, so testing it there will not run your actions. Test right-click in preview.
Notes
What it shows Changed in 1.0.8
One of three things, and the first that is set wins:
| Set | It shows | Driven by |
|---|---|---|
symbol3d | One 3D symbol from the Symbol Library | params — bind each to a tag |
equipment | A plant of 3D symbols, each placed with a position, rotation and scale, standing on model (a building or site) when one is set | Each item's own params |
model | A plain .glb file | Nothing — see below |
A 3D symbol carries its own rules — fill, level, rotate, spin, flow, blink, text — written once in the 3D Symbol Editor and applied to every copy, the way a 2D symbol's are. So the viewer only has to hand it values. Number-driven states (a level, an angle, an offset, a scale, an opacity) glide to each new value over 600 ms rather than jumping.
Drop a 3D symbol from the Symbol Library onto the viewer to add it to equipment. Its parts are then named <id>/<part> (TK101/Shell) wherever a part is picked — camera.focus, a pinned widget, an objectClick part handler.
showLabels hides, in this viewer only, the value labels a symbol's Text rules put on its parts. To remove one from every copy, delete the rule in the symbol.
Up to 1.0.7 the viewer drove a .glb directly: a bindings list tied a node's colour, emissive, state, opacity, visibility, position, rotation, scale or morph target to a tag, and a labels list pinned live readouts to nodes. Both have been removed, along with output.morphTargets, and a saved view is not converted — a viewer that used them still shows its model, but nothing on it follows a tag any more. To drive a .glb from tags, turn it into a 3D symbol — in the symbol library's add menu, Build from a 3D model… — write the colouring, motion and labels as its rules, and point the viewer's symbol3d at it. A live readout pinned to a part is now an ordinary widget pinned with anchor (below).
Widgets on the scene New in 1.0.8
The viewer is a container. Place widgets or Embedded Views inside it and each one's anchor decides where it sits:
- On the screen (
mode: screen) — at its x/y, like a heads-up panel that ignores the camera. - On a part (
mode: node) — pinned to a part of the model and redrawn there every frame as the camera moves, with a leader line.pointpicks the top, centre or bottom of the part,alignwhich side of that point the widget hangs, andoffsetX/offsetYnudge it.occludehides it while the part is behind something;hideBeyondhides it beyond a distance, so a far-off plant does not drown in faceplates. Overlapping pinned widgets are spread apart.
On the canvas, drag a pinned widget to change its offset (one undo step) or nudge it with the arrow keys; its selection box follows the camera.
Many viewers on one screen
A viewer scrolled or switched out of sight stops drawing, and after 10 seconds hands its WebGL context back to the browser. New in 1.0.8 Browsers allow only a limited number of contexts at once, and the Designer warns when more than 12 viewers are drawing.
The camera
camera.focus New in 1.0.8 names a part to look at: the camera flies to it and frames it, keeping the direction it looks from. Bind it — to a tag, to a client variable set by buttons, or to a dropdown's value — and every change flies to the new part. Clearing it flies back to the normal view.
look.environment New in 1.0.8 is what the scene reflects and is softly lit by: studio, outdoor, plant (a hall under overhead lights) or none. All are built in and nothing is downloaded. auto uses studio for 3D symbols and plants and none for a .glb, which keeps the look it was made with.
Three numbers decide the view: where the camera sits (camera.position), the point it looks at (camera.target, which is also the pivot an operator orbits around) and how wide the lens is (camera.fov). Leave camera empty and the model is framed automatically; the moment a position is filled in, that framing is replaced.
Do not type them. Enter the widget, click Set camera, aim the view, then Save — the tool writes the pose you are looking at. Clicking + on the property fills the whole tuple at its default in one click, which is the fallback for typing one in by hand.
Motion is eased by default. New in 1.0.6 orbit.smooth covers both directions at once: an operator's rotate, pan and zoom glide to a stop instead of snapping, and a new pose — a target re-aimed by an event action, a bound tag or an edit here — flies there rather than cutting, so nobody loses track of which way round the equipment is. Any gesture cancels a flight in progress, and the first camera and the auto-fit still land instantly. orbit.smoothness is how long a glide lasts: 0.5 is about 300 ms and suits a control screen; raise it for a lobby display.
orbit.enableRotate is the third gesture switch, beside zoom and pan. New in 1.0.6 Off pins the viewing angle — a fixed overview an operator cannot tumble into a useless view.
Reading what it publishes
Everything the viewer reports back — the picked object, the current camera, the model's parts and animation clips — lives under one output object, so a binding reads:
{{ <Viewer3D>.output.selectedObject.name }}
{{ <Viewer3D>.output.cameraView }}
{{ <Viewer3D>.output.animationClips }}
The picked object and each entry of sceneNodes also carry a label New in 1.0.8 — the part's label from its 3D symbol, or its name when it has none — so show .label and address by .name. output.morphTargets was removed in 1.0.8 with the per-node bindings that used it.
They were top-level props once, and three of them mirrored an authored input by name — cameraView beside camera, animationClips beside animation — so the property grid read as though the same thing were declared twice and a binding author could not tell which one to bind from. Grouping them says which side of the widget each belongs to.
Clicking one object rather than the whole viewer
Objects in the model are addressable parts: each can carry its own objectClick and objectDoubleClick, wired in the Event Manager under its node name. A click on a multi-primitive node is walked up to the deepest ancestor you wired, so the name you see in the model tree is the name to use.
→ Events on a part of a widget
Performance
Large models are the most expensive thing you can put on a screen. Check it on the actual panel hardware, not on your laptop.