beckhoff-xts-viewer-3d
v4.8.0
Published
Reusable React component for rendering Beckhoff XTS linear-motor systems in 3D using Three.js
Downloads
1,652
Maintainers
Readme
beckhoff-xts-viewer-3d
A reusable React component that renders Beckhoff XTS linear-motor systems
(plus Hepco GFX rail variants) in 3D. Drop a single <XtsViewer3D> into a
React app, hand it a config that describes your modules + movers + tools, and
the viewer takes care of the path math, GLB loading, mover animation,
selection, calibration, multi-track placement, PBR-realistic lighting,
soft shadows, and a CAD-style ViewCube.
Functionally mirrors the official 2D Beckhoff.TwinCAT.HMI.XTS.Controls
viewer — but with full 3D, real CAD geometry, free orientation, and a clean
declarative API.

import { XtsViewer3D } from 'beckhoff-xts-viewer-3d';
<XtsViewer3D
config={{
processingUnits: [
{
objectId: 0,
moverType: 'AT9014_0055',
parts: [
{
objectId: 0,
globalNumber: 0,
modules: [
{ moduleType: 'AT2001_0250', globalNumber: 1 },
{ moduleType: 'AT2000_0250', globalNumber: 2 },
{ moduleType: 'AT2050_0500', globalNumber: 3 },
{ moduleType: 'AT2050_0501', globalNumber: 4 },
{ moduleType: 'AT2001_0250', globalNumber: 5 },
{ moduleType: 'AT2000_0250', globalNumber: 6 },
{ moduleType: 'AT2050_0500', globalNumber: 7 },
{ moduleType: 'AT2050_0501', globalNumber: 8 },
],
},
],
movers: [
{ index: 0, id: 0, partOid: 0, partPositionMm: 200 },
],
},
],
}}
/>;That's it. No asset hosting required — GLBs stream from the matching
beckhoff-xts-viewer-3d-assets release on jsDelivr. PBR
reflections, ACES tone mapping and anisotropic textures are on by default.
Table of contents
- Highlights
- Gallery
- Installation
- Getting started
- Realism + performance
- Troubleshooting
- Documentation
- Development setup
- Releasing
- License
Highlights
- Every module + mover variant in the Beckhoff catalogue — Standard AT, Eco AT2200, NCT (AT2002 / AT2102 + AT8200 tools), Hygienic ATH, plus Hepco GFX2-1TC-S25. Drop in a STP file, wire the type — done.
- Path math 1:1 with the 2D reference — straights, ±22.5° / ±45° curves, AT2050 / ATH2050 180° clothoid kehres. Module-to-module C0/C1 continuity guaranteed by golden fixtures.
- PBR-realistic rendering — ACES filmic tone mapping, image-based
lighting via a procedural indoor environment, anisotropic textures, soft
PCF shadows. Zero asset fetches; the environment map is built on-device
from three.js's
RoomEnvironment. All tunable viadisplay.*props or off-by-default for direct-lighting parity. - Mover animation via imperative ref —
viewerRef.current.setMoverPositions(...)bypasses React reconciliation entirely (useFrame + Three.js scene-graph), so a 60-Hz drive loop costs zero React renders. - Selection + drive status — click to select modules / movers; the GLB itself tints / blinks (no wireframe overlays), and a small modern 3D status icon (warning ▲ / error ⊙) floats above affected objects.
- Multi-track — per-XPU
trackTransform(position + rotation + uniform scale) so independent XTS lines can sit side-by-side in one scene. - Live calibration overrides — push origin-correction edits to module / mover / tool sidecars in real time without touching files.
- Stations, Areas, Dimensions, InfoBars — full 2D feature parity, plus camera-facing mm-value labels, intermediate ticks, and a 7-shape stop-marker palette (Diamond / Tick / Sphere / Cone / Cube / Cylinder / None) settable per-station. Areas are stop-position-free zone overlays (cleanroom, safety loop, manual access) — text + colour, multi-part. Stop-position values can be track-relative (default) or station-relative.
- Stop-position ghost movers —
display.showStopPositionMoversrenders a static, semi-transparent mover GLB at every active stop, tinted to the station colour by default. Useful for layout reviews ("where will the mover end up"). - Mover collision detection — sub-millimetre 1D arc-length test on the
shared chain. One-shot via
viewerRef.current.checkMoverCollisions()or continuous via<XtsViewer3D collisionDetection={{ enabled, onCollisionsChange }} />. Closed-loop seam handled automatically. - Stator heatmap overlay — coloured tube along each part's centerline
with vertex colours linearly interpolated across consumer-supplied
(positionMm, value)samples. Default green → red gradient; configurable min / max colours, thickness, opacity, lateral / vertical offset. - Screenshots —
viewerRef.current.exportScreenshot({ mode })renders to an offscreen target at any resolution.'current'captures the live camera,'top-down'produces a 2D-viewer-style overhead AABB-fit,'custom'reproduces a savedCameraState. Returns a Blob plus camera state + bounding box for reproducible exports. - Track position frame — per-XPU
positionFrame: { direction, originMm }remaps everypartPositionMm-style value (movers, stations, areas, stops, ghosts, world transforms) into the host's coordinate convention. Reverse direction or shift zero without editing any other field. - Custom assets — static, mover-bound, all-movers; opacity + scale per-instance, never leaks back into the source GLB.
- CAD ViewCube — opt-in, snap to standard orthogonal views.
- Live 2D plan view —
projection="orthographic"flips the live canvas to a flat top-down view, pixel-consistent with the'top-down'screenshot, with no WebGL-context remount and rotation auto-locked. - Animated focus —
viewerRef.current.focusOn({ kind: 'station' | 'area' | 'mover' | 'module' | 'scene', ... })flies the camera so the whole target fits the frame, in both 3D and 2D. - Imperative ref API —
zoomToFit/frameTopDown/focusOn/setCamera/getCamera/getMoverWorldTransform/getBoundingBox/exportModel/exportScreenshot/setMoverPosition(s)(Record orMoverPositionEntry[]indexed byMoverConfig.index) /getMoverPosition/setModuleStatuses/clearModuleStatuses/checkMoverCollisions/reloadAssets. - Designed for scale — the
⚡ Perf stressdemo runs three ovals × 250 movers each (= 750 simultaneously animated movers) without React commits during the steady state.
Gallery
| | |
|---|---|
|
Multi-track placement — two independent XTS lines composed via trackTransform. |
Stations + Areas — cleanroom / safety-area zone overlays, station tubes with stop markers. |
|
Stator heatmap — vertex-colour gradient along the centerline, fed from your live drive currents. |
Sub-mm collision detection — continuous monitor with banner; pair-wise 1D arc-length test on the shared chain. |
|
Drive status — emissive blink at 1 Hz on the GLB itself + camera-facing 3D icons (▲ warning, ⊙ error). |
Perf stress — 750 movers animated at 60 Hz with zero React commits in steady state. |
|
PCF-soft shadows + IBL — opt-in shadows on a transparent canvas; image-based lighting on by default. | 
exportScreenshot('top-down') — orthographic, AABB-fit, mirrors the 2D viewer convention. |
Installation
npm install beckhoff-xts-viewer-3dPeer dependencies (you almost certainly have these already):
npm install react react-dom threeCompatibility:
- React ≥ 18 (tested on 19)
- Three.js ≥ 0.150
- Modern bundler (Vite, Webpack, Next.js, Remix, Astro, plain CRA — all fine)
The package is "type": "module" and ships ESM + CJS via
./dist/index.{js,cjs,d.ts}. sideEffects: false so unused exports
tree-shake out.
Getting started
1. GLB assets — zero-config by default
The viewer's default assetsBaseUrl points at the jsDelivr CDN, version-
pinned to the matching beckhoff-xts-viewer-3d-assets
release:
https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d-assets@<version>/modelsSo in the typical case there is nothing to install or host — drop in
<XtsViewer3D config={…} /> and the GLBs stream from jsDelivr.
Calibration metadata (origin-correction, path lengths, AABBs) is compiled into the JS bundle, so the viewer never makes a sidecar HTTP request for known module / mover / tool types.
Self-hosting
If you can't reach jsDelivr (air-gapped network, corporate proxy, regulated environment), install the assets package and serve the GLBs yourself:
npm install beckhoff-xts-viewer-3d-assetsCopy node_modules/beckhoff-xts-viewer-3d-assets/models into your
app's static folder during build, and point at it:
<XtsViewer3D config={cfg} assetsBaseUrl="/models" />Or use any other URL prefix:
<XtsViewer3D config={cfg} assetsBaseUrl="https://cdn.example.com/xts/" />Why a separate assets package?
The viewer JS bundle is ~110 kB compressed. The CAD-derived GLBs total
~28 MB. Splitting them lets npm install beckhoff-xts-viewer-3d
stay tiny, while the assets are version-pinned and fetched on demand from
a globally cached CDN.
2. Build a config
The minimum: one ProcessingUnitConfig with one Part containing your
module list and one or more Movers. See the snippet at the top of this
README for a working oval loop.
The component fills its parent (width: 100%; height: 100%) — make sure
the parent has a definite height. The canvas is transparent: whatever
sits behind the host element shows through, so wrap in a styled container
if you want a solid backdrop.
3. Drive mover positions
Movers don't animate themselves — your app pushes positions through the imperative ref:
import { useRef, useEffect } from 'react';
import { XtsViewer3D, type XtsViewer3DRef } from 'beckhoff-xts-viewer-3d';
function App() {
const viewerRef = useRef<XtsViewer3DRef>(null);
useEffect(() => {
let raf = 0;
const tick = (t: number) => {
viewerRef.current?.setMoverPosition(0, (t / 5) % 3000);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return <XtsViewer3D ref={viewerRef} config={config} />;
}The push goes straight into a per-component store; <XtsMover> reads it in
useFrame and mutates its Three.js group. Zero React renders per tick.
For state-driven flows the legacy contract still works: just keep
MoverConfig.partPositionMm updated in the config and pass the new config
through. Whichever you set last wins (imperative store > config prop).
4. Read selection + errors
<XtsViewer3D
config={config}
selectionMode="Single" // | 'Off' | 'Multi'
onSelectionChange={(s) => console.log('selection', s)}
onError={(err) => console.error(err.code, err.message)}
/>SelectionState carries { modules: ModuleRef[], movers: MoverRef[] }.
Errors flow through onError with typed codes:
asset-load-failed | unknown-module-type | unmatched-clothoid-half | ….
5. Detect mover collisions
Two flavours — pick one.
Continuous monitoring (callback fires when the collision set changes):
import type { MoverCollision } from 'beckhoff-xts-viewer-3d';
<XtsViewer3D
config={config}
collisionDetection={{
enabled: true,
warningGapMm: 0, // 0 = real collisions; > 0 also reports near-misses
intervalMs: 0, // 0 = check every frame; e.g. 50 for 20 Hz
onCollisionsChange: (collisions: MoverCollision[]) => {
// Fires only when the set actually changes (pair appears / disappears
// / penetrationMm shifts by > 0.01 mm).
if (collisions.length) console.warn('crash:', collisions[0]);
},
}}
/>;One-shot query via the imperative ref:
const list = viewerRef.current?.checkMoverCollisions({ warningGapMm: 5 });
// → MoverCollision[] sorted deepest-penetration firstEach MoverCollision carries:
{
a: MoverRef; b: MoverRef;
idA: string; idB: string;
penetrationMm: number; // > 0 = overlap, 0 = touching, < 0 = warning gap
positionAMm: number; positionBMm: number;
pathLengthAMm: number; pathLengthBMm: number;
viaWraparound: boolean; // true when measured across the closed-loop seam
}Sub-millimetre accurate (pure float64 arc-length math). The check covers movers travelling on the same chain; cross-track collisions in multi-XPU setups are out of scope.
6. Capture screenshots
Trigger from anywhere in your app via the imperative ref:
const viewer = useRef<XtsViewer3DRef>(null);
async function saveTopDown() {
const result = await viewer.current!.exportScreenshot({
mode: 'top-down', // | 'current' | 'custom'
pixelRatio: 2, // 2× sharpness even on a 1× display
paddingFactor: 1.15,
format: 'png', // | 'jpeg' | 'webp'
backgroundColor: null, // null = transparent PNG; '#0e1116' for a solid bg
});
// result: { blob, widthPx, heightPx, camera, boundingBoxMm, mode }
saveAs(result.blob, `layout-${Date.now()}.png`);
}'top-down' mirrors the 2D viewer: orthographic camera centred on the
scene's bounding box, world +X = image right, world +Y = image up.
'custom' lets you reproduce a saved framing exactly:
viewer.current!.exportScreenshot({
mode: 'custom',
camera: previousResult.camera, // round-trip a saved CameraState
});Renders go to an offscreen WebGLRenderTarget — the live canvas keeps
running at full speed, no preserveDrawingBuffer perf cost.
7. Live 2D plan view (orthographic top-down)
Set projection="orthographic" to switch the live canvas into a flat 2D
plan view — straight down +Z, world +Y up — that is pixel-consistent with
exportScreenshot({ mode: 'top-down' }). Switching at runtime does not
recreate the WebGL context, so toggling between 3D and 2D is instant.
const [is2D, setIs2D] = useState(false);
<XtsViewer3D
config={config}
projection={is2D ? 'orthographic' : 'perspective'}
// shadows add nothing to a flat plan — drop them in 2D
display={is2D ? { ...display, shadows: false } : display}
/>In orthographic top-down, rotation is auto-disabled (a 2D plan has no
meaningful orbit); pan and zoom stay on. Set lock={{ rotate: false }} to
opt rotation back in. The frustum re-fits automatically on container resize
and whenever the scene's bounding box changes. From the ref, frameTopDown()
re-fits on demand and zoomToFit() adjusts the ortho frustum (instead of
dollying) when the live camera is orthographic.
8. Fly the camera to an object (focusOn)
viewerRef.current.focusOn(target, opts) animates the camera so a station,
area, mover, module — or the whole scene — fits the frame. In perspective
the current view angle is preserved (the camera only dollies + re-centres); in
orthographic top-down the frustum re-frames and the camera pans straight over
the target. Works in both 2D and 3D.
// Frame a station, 700 ms ease-in-out (defaults)
viewerRef.current?.focusOn({ kind: 'station', stationId: 3 });
// Frame a mover, faster
viewerRef.current?.focusOn(
{ kind: 'mover', ref: { processingUnitObjectId: 0, moverIndex: 2 } },
{ durationMs: 500 },
);
// Frame an area / a module / the whole scene
viewerRef.current?.focusOn({ kind: 'area', areaId: 1 });
viewerRef.current?.focusOn({
kind: 'module',
ref: { processingUnitObjectId: 0, partObjectId: 10, moduleIndex: 4 },
});
viewerRef.current?.focusOn({ kind: 'scene' }, { durationMs: 0 }); // 0 = jumpFocusOptions: durationMs (default 700, 0 jumps), paddingFactor
(default 1.2 perspective / 1.1 ortho), easing ('easeInOutCubic' |
'linear'). A new focusOn call supersedes any in-flight animation.
9. Mark zones with Areas
Areas are stop-position-free range overlays — like Stations, but with no markers. Use them for cleanroom / safety-area / manual-access zones that don't drive any mover behaviour:
<XtsViewer3D
config={{
processingUnits: [/* … */],
areas: [
{
areaId: 1,
description: 'Cleanroom',
isEnabled: true,
partOids: [0],
startPositionOnPart: 250,
endPositionOnPart: 1000,
color: 0xff_4d_9d_e0, // ARGB
},
],
}}
display={{
areaOptions: {
thicknessMm: 8,
displacementMm: 60,
opacity: 0.7,
showAreaDescription: true,
},
}}
/>;10. Stator heatmap
Coloured tube along each part's centerline, vertex colours interpolated
across consumer-supplied (positionMm, value) samples — perfect for
streaming live drive currents or stator temperatures:
import type { StatorHeatmap } from 'beckhoff-xts-viewer-3d';
const heatmap: StatorHeatmap = {
parts: [{ partOid: 0, samples: [{ positionMm: 0, value: 25 }, /* … */] }],
min: 0, max: 100,
minColor: '#22c55e', // default green
maxColor: '#ef4444', // default red
};
<XtsViewer3D
config={config}
statorHeatmap={heatmap}
display={{ showStatorHeatmap: true }}
/>;11. Track direction + zero offset
When the host machine uses a different sign convention or zero point
than the GLB chain, set a positionFrame on the XPU. Movers, stations,
areas, stops, ghosts, and getMoverWorldTransform all follow:
processingUnits: [
{
objectId: 0,
moverType: 'AT9014_0055',
positionFrame: { direction: 'negative', originMm: 1500 },
parts: [/* … */],
movers: [
{ index: 0, id: 0, partOid: 0, partPositionMm: 0 },
],
},
]For more — every prop, every ref method, every helper, every type — read docs/USING-THE-COMPONENT.md.
Realism + performance
The viewer is tuned to look like a CAD-quality render out of the box while
holding 60 Hz on mid-range integrated GPUs. Every realism feature is
opt-out via display.* so consumers who liked the old direct-lighting
look can revert with a single prop.
| Feature | Default | Knob | Cost |
|---|---|---|---|
| Image-based lighting (PMREM-prefiltered RoomEnvironment) | on | display.environmentLighting | one-time PMREM build (~1.5 MB GPU); zero per-frame overhead beyond standard PBR shader |
| Environment intensity | 0.4 | display.environmentIntensity | — |
| ACES Filmic tone mapping | on | display.toneMapping ('aces' \| 'linear' \| 'reinhard' \| 'cineon' \| 'agx' \| 'none') | shader-side, ~free |
| Tone-mapping exposure | 1.0 | display.toneMappingExposure | — |
| Anisotropic texture filtering (max hardware) | on | (always on; pure sampler state) | none |
| castShadow / receiveShadow on every GLB mesh | on | (auto) | none unless shadows are enabled |
| PCF-soft shadows on the directional light | off | display.shadows | one extra render pass on the shadow map (4096²) |
| Shadow-catcher plane (transparent ground) | tied to shadows | — | trivial |
| Hemisphere fill (when env lighting is off) | on (when no IBL) | (auto) | vertex-frequency |
| Auto-pause render loop when tab hidden | on | performance.autoPauseOnHidden | — |
| Mover updates via MoverPositionStore (no React commits) | always | — | — |
The PBR pipeline picks up automatically: any MeshStandardMaterial /
MeshPhysicalMaterial already exported in your GLBs (the standard glTF
metal/rough workflow) inherits scene.environment for reflections.
Custom GLBs with non-PBR materials are unaffected — they render exactly
the same.
If you need flat direct-lighting parity:
<XtsViewer3D
config={config}
display={{
environmentLighting: false, // disable IBL
toneMapping: 'none', // no tone-mapping curve
}}
/>Stress baseline: ⚡ Perf stress (3 ovals × 250 movers = 750 mover groups
animated at 60 Hz) runs steady at ~16 ms/frame in Chrome on a mid-range
laptop with 0 React commits in steady state, IBL + ACES + anisotropy on.
Troubleshooting
Modules render as yellow boxes, movers as blue boxes
Symptom. GLBs never appear; the viewer shows wireframe placeholders
(modules in #FFB000, movers in #3D88E0) and the browser console
prints THREE.WARNING: Multiple instances of Three.js being imported.
No models/*.glb requests show up in the Network panel.
Cause. A transitive dep (stats-gl, via @react-three/drei) pins
three in its own dependencies, so npm installs a second
three-copy under node_modules/stats-gl/node_modules/three. The two
copies produce two THREE.* namespaces; useGLTF's instanceof
checks fail across the boundary and silently reject every parsed scene.
Fix. Force your bundler to deduplicate three. For Vite, add
resolve.dedupe:
// vite.config.ts
export default defineConfig({
plugins: [react()],
resolve: { dedupe: ['three'] },
});Webpack / Next.js: alias three to your root node_modules/three. See
USING-THE-COMPONENT.md § Bundler configuration
for the full snippets and how to bust Vite's pre-bundle cache after the
change.
Documentation
- docs/USING-THE-COMPONENT.md — consumer guide: install, asset hosting, every prop, every ref method, recipes, performance tuning.
- docs/ADDING-A-MODULE.md — developer guide: add a new module / mover / tool type from STP to calibrated GLB.
- docs/RELEASING.md — one-click release flow: how to publish a new version, npm Trusted Publishers setup, calibration safety, emergency manual release.
- docs/screenshots/README.md — how to refresh the README + docs gallery from the playground.
Development setup
This repo is a pnpm workspace — pnpm-lock.yaml is the source of
truth and npm ci / npm install will not work. Get pnpm via
npm install -g pnpm@10 (or any other installer), then:
pnpm install
pnpm test # 310 unit + property tests
pnpm typecheckRun the playground
The playground is a small Vite app at playground/ that demonstrates every
feature — selection, calibration, composer, multi-track, shadows, ViewCube,
drive-status icons, the perf stress test, IBL toggle, intensity slider.
pnpm dev # http://127.0.0.1:5173Sidebar controls let you switch demos, animate movers, toggle shadows / IBL / ViewCube / theme, drag-and-drop tracks together in the composer, and live-edit calibration overrides.
Build the library
pnpm build # → dist/
pnpm playground:buildAsset pipeline
CAD source files (stepfiles/*.stp) are converted to runtime-ready GLBs
and per-asset JSON sidecars. To regenerate after touching a STP:
pnpm assets:convert # STP → GLB via occt-import-js
pnpm assets:inspect # refresh docs/data/glb-inspection.json
pnpm assets:generate-sidecars # module .meta.json (origin-correction)
pnpm assets:generate-mover-sidecarsThe sidecar generators are idempotent: existing files are skipped so they
never stomp hand-tuned calibration values. Pass --force to regenerate.
The release pipeline never invokes the generators — bundle-sidecars
reads existing JSONs read-only into the JS bundle. See
docs/RELEASING.md.
To add a new module / mover / tool type from scratch — naming convention, type registration, sidecar generation, calibration workflow — follow docs/ADDING-A-MODULE.md.
Project layout
.
├── src/ Library source — published as the npm package
│ ├── components/ <XtsViewer3D> + internal scene tree
│ ├── geometry/ Path math, ChainBuilder, normalizeXtsConfig, …
│ ├── assets/ AssetManifest, SidecarLoader, AssetLoader
│ └── interaction/ SelectionManager
├── packages/
│ └── assets/ Sibling npm package (GLB-only mirror)
├── playground/ Vite app exercising every feature
├── public/models/ GLBs + .meta.json sidecars (sources)
├── stepfiles/ Source CAD STP files (NOT shipped)
├── scripts/ Asset pipeline + version-sync utilities
├── docs/
│ ├── USING-THE-COMPONENT.md Consumer guide (props, ref API, recipes)
│ ├── ADDING-A-MODULE.md How to register a new module / mover / tool
│ ├── RELEASING.md How to publish a new release
│ ├── screenshots/ README + docs gallery
│ └── data/ GLB AABB inspection JSON
├── .github/workflows/ release.yml + release-assets.yml + deploy-docs.yml
└── README.mdTests
vitest covers path math, normalize-config, chain-building, sample helpers,
selection logic, asset URL composition, the composer reducer, dimension
ticks, and the multi-track transform composition. Run focused:
pnpm vitest run src/geometry/__tests__/ChainBuilder.test.tsReleasing
The pipeline is fully automated and triggered by every push to
main. semantic-release reads
your Conventional Commit messages, decides the next version, writes
CHANGELOG.md, publishes both packages to npm, and opens a GitHub
Release.
git push origin main # commits like `fix: …`, `feat: …`, `feat!: …`
↓
.github/workflows/release.yml
↓
npx semantic-release # version bump + npm publish + GH releaseYou never call npm version, write a changelog, create a tag, or
run npm publish by hand. Commit-type → bump:
| Prefix | Bump |
|---|---|
| fix: / perf: | patch |
| feat: | minor |
| feat!: / fix!: / BREAKING CHANGE: footer | major |
| chore: / docs: / ci: / refactor: / test: | none (no release) |
Plus a SHA-256 guard around public/models/*.meta.json so hand-tuned
calibration values can never be overwritten by the pipeline.
Preview the next release locally:
pnpm release:dry-runSee docs/RELEASING.md for the full guide: plugin order, calibration safety, Trusted Publishers graduation, and emergency manual flow.
License
MIT — see LICENSE.
The Beckhoff CAD source files under stepfiles/ are included for
verification only and remain subject to their respective Beckhoff
licensing terms — they are not shipped with the published npm package.
