beckhoff-xts-viewer-3d 4.10.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,21 +3,45 @@
3
3
  [![npm](https://img.shields.io/npm/v/beckhoff-xts-viewer-3d.svg)](https://www.npmjs.com/package/beckhoff-xts-viewer-3d)
4
4
  [![npm assets](https://img.shields.io/npm/v/beckhoff-xts-viewer-3d-assets.svg?label=assets)](https://www.npmjs.com/package/beckhoff-xts-viewer-3d-assets)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
- [![Release](https://github.com/philippleidig/beckhoff-xts-viewer-3d/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/philippleidig/beckhoff-xts-viewer-3d/actions/workflows/release.yml)
6
+ [![CI](https://github.com/philippleidig/beckhoff-xts-viewer-3d/actions/workflows/ci.yml/badge.svg)](https://github.com/philippleidig/beckhoff-xts-viewer-3d/actions/workflows/ci.yml)
7
7
 
8
- A reusable React component that renders **Beckhoff XTS** linear-motor systems
9
- (plus Hepco GFX rail variants) in 3D. Drop a single `<XtsViewer3D>` into a
10
- React app, hand it a config that describes your modules + movers + tools, and
11
- the viewer takes care of the path math, GLB loading, mover animation,
12
- selection, calibration, multi-track placement, **PBR-realistic lighting**,
13
- soft shadows, and a CAD-style ViewCube.
8
+ A React component that renders Beckhoff XTS linear-motor systems — and Hepco
9
+ GFX rail variants in 3D. It takes a declarative description of modules,
10
+ movers and tools and handles the path math, GLB loading, mover animation,
11
+ selection, lighting and camera work.
14
12
 
15
- Functionally mirrors the official 2D `Beckhoff.TwinCAT.HMI.XTS.Controls`
16
- viewer but with full 3D, real CAD geometry, free orientation, and a clean
17
- declarative API.
13
+ It covers the same ground as the official 2D `Beckhoff.TwinCAT.HMI.XTS.Controls`
14
+ viewer, using real CAD geometry in three dimensions.
18
15
 
19
16
  ![Oval loop demo](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/01-oval-loop.png)
20
17
 
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install beckhoff-xts-viewer-3d
22
+ npm install react react-dom three
23
+ ```
24
+
25
+ | Requirement | Version | Why |
26
+ | ------------------------------------------------ | ------------------------ | -------------------------------------------------- |
27
+ | `react` / `react-dom` | `^19.0.0` | required by `@react-three/fiber` 9 |
28
+ | `three` | `>= 0.181.0` | the viewer uses the console-hook API added in r181 |
29
+ | `@react-three/fiber` | `>= 9.0.0` | |
30
+ | `@react-three/drei` | `>= 10.0.0` | |
31
+ | `three-stdlib` | `>= 2.36.0` | GLTF, KTX2 and Meshopt loaders |
32
+ | `postprocessing` + `@react-three/postprocessing` | `>= 6.39.2` / `>= 3.0.0` | optional — only needed for SSAO and bloom |
33
+ | Node | `>= 20` | build tooling only; the runtime is browser ESM |
34
+
35
+ The package is `"type": "module"` and ships ESM and CJS from
36
+ `./dist/index.{js,cjs,d.ts}`. `sideEffects: false`, so unused exports
37
+ tree-shake out.
38
+
39
+ Any modern bundler works (Vite, Webpack, Next.js, Remix, Astro). If your
40
+ bundler installs more than one copy of `three`, deduplicate it — see
41
+ [Troubleshooting](#troubleshooting).
42
+
43
+ ## Minimal example
44
+
21
45
  ```tsx
22
46
  import { XtsViewer3D } from 'beckhoff-xts-viewer-3d';
23
47
 
@@ -43,548 +67,120 @@ import { XtsViewer3D } from 'beckhoff-xts-viewer-3d';
43
67
  ],
44
68
  },
45
69
  ],
46
- movers: [
47
- { index: 0, id: 0, partOid: 0, partPositionMm: 200 },
48
- ],
70
+ movers: [{ index: 0, id: 0, partOid: 0, partPositionMm: 200 }],
49
71
  },
50
72
  ],
51
73
  }}
52
74
  />;
53
75
  ```
54
76
 
55
- That's it. No asset hosting required GLBs stream from the matching
56
- `beckhoff-xts-viewer-3d-assets` release on jsDelivr. PBR
57
- reflections, ACES tone mapping and anisotropic textures are on by default.
58
-
59
- ---
60
-
61
- ## Table of contents
62
-
63
- - [Highlights](#highlights)
64
- - [Gallery](#gallery)
65
- - [Installation](#installation)
66
- - [Getting started](#getting-started)
67
- - [GLB assets zero-config by default](#1-glb-assets--zero-config-by-default)
68
- - [Build a config](#2-build-a-config)
69
- - [Drive mover positions](#3-drive-mover-positions)
70
- - [Read selection + errors](#4-read-selection--errors)
71
- - [Detect mover collisions](#5-detect-mover-collisions)
72
- - [Capture screenshots](#6-capture-screenshots)
73
- - [Live 2D plan view (orthographic top-down)](#7-live-2d-plan-view-orthographic-top-down)
74
- - [Fly the camera to an object (focusOn)](#8-fly-the-camera-to-an-object-focuson)
75
- - [Mark zones with Areas](#9-mark-zones-with-areas)
76
- - [Stator heatmap](#10-stator-heatmap)
77
- - [Track direction + zero offset](#11-track-direction--zero-offset)
78
- - [Realism + performance](#realism--performance)
79
- - [Troubleshooting](#troubleshooting)
80
- - [Documentation](#documentation)
81
- - [Development setup](#development-setup)
82
- - [Releasing](#releasing)
83
- - [License](#license)
84
-
85
- ---
86
-
87
- ## Highlights
88
-
89
- - **Every module + mover variant** in the Beckhoff catalogue Standard AT,
90
- Eco AT2200, NCT (AT2002 / AT2102 + AT8200 tools), Hygienic ATH, plus Hepco
91
- GFX2-1TC-S25. Drop in a STP file, wire the type done.
92
- - **Path math 1:1 with the 2D reference** straights, ±22.5° / ±45° curves,
93
- AT2050 / ATH2050 180° clothoid kehres. Module-to-module C0/C1 continuity
94
- guaranteed by golden fixtures.
95
- - **PBR-realistic rendering** — ACES filmic tone mapping, image-based
96
- lighting via a procedural indoor environment, anisotropic textures, soft
97
- PCF shadows. Zero asset fetches; the environment map is built on-device
98
- from three.js's `RoomEnvironment`. All tunable via `display.*` props or
99
- off-by-default for direct-lighting parity.
100
- - **Mover animation via imperative ref** — `viewerRef.current.setMoverPositions(...)`
101
- bypasses React reconciliation entirely (useFrame + Three.js scene-graph),
102
- so a 60-Hz drive loop costs zero React renders.
103
- - **Selection + drive status** click to select modules / movers; the GLB
104
- itself tints / blinks (no wireframe overlays), and a small modern 3D status
105
- icon (warning / error ⊙) floats above affected objects.
106
- - **Feed segment highlights (Einspeisestränge)** `feedSegmentHighlights`
107
- tints whole electrical strands: every module from one Einspeisemodul up to
108
- the next, several strands at once, each in its own colour. Closed loops wrap
109
- the seam automatically. `viewerRef.current.getFeedSegments()` lists what a
110
- layout contains.
111
- - **Multi-track** — per-XPU `trackTransform` (position + rotation +
112
- uniform scale) so independent XTS lines can sit side-by-side in one scene.
113
- - **Live calibration overrides** — push origin-correction edits to module /
114
- mover / tool sidecars in real time without touching files.
115
- - **Stations, Areas, Dimensions, InfoBars** — full 2D feature parity, plus
116
- camera-facing mm-value labels, intermediate ticks, and a 7-shape stop-marker
117
- palette (Diamond / Tick / Sphere / Cone / Cube / Cylinder / None) settable
118
- per-station. **Areas** are stop-position-free zone overlays (cleanroom,
119
- safety loop, manual access) — text + colour, multi-part.
120
- Stop-position values can be track-relative (default) or station-relative.
121
- - **Stop-position ghost movers** — `display.showStopPositionMovers` renders a
122
- static, semi-transparent mover GLB at every active stop, tinted to the
123
- station colour by default. Useful for layout reviews ("where will the
124
- mover end up").
125
- - **Mover collision detection** — sub-millimetre 1D arc-length test on the
126
- shared chain. One-shot via `viewerRef.current.checkMoverCollisions()` or
127
- continuous via `<XtsViewer3D collisionDetection={{ enabled, onCollisionsChange }} />`.
128
- Closed-loop seam handled automatically.
129
- - **Stator heatmap overlay** — coloured tube along each part's centerline
130
- with vertex colours linearly interpolated across consumer-supplied
131
- `(positionMm, value)` samples. Default green → red gradient; configurable
132
- min / max colours, thickness, opacity, lateral / vertical offset.
133
- - **Screenshots** — `viewerRef.current.exportScreenshot({ mode })` renders to
134
- an offscreen target at any resolution. `'current'` captures the live camera,
135
- `'top-down'` produces a 2D-viewer-style overhead AABB-fit, `'custom'`
136
- reproduces a saved `CameraState`. Returns a Blob plus camera state +
137
- bounding box for reproducible exports.
138
- - **Track position frame** — per-XPU `positionFrame: { direction, originMm }`
139
- remaps every `partPositionMm`-style value (movers, stations, areas, stops,
140
- ghosts, world transforms) into the host's coordinate convention. Reverse
141
- direction or shift zero without editing any other field.
142
- - **Custom assets** — static, mover-bound, all-movers; opacity + scale
143
- per-instance, never leaks back into the source GLB.
144
- - **CAD ViewCube** — opt-in, snap to standard orthogonal views.
145
- - **Live 2D plan view** — `projection="orthographic"` flips the live canvas to
146
- a flat top-down view, pixel-consistent with the `'top-down'` screenshot, with
147
- no WebGL-context remount and rotation auto-locked.
148
- - **Animated focus** — `viewerRef.current.focusOn({ kind: 'station' | 'area' |
149
- 'mover' | 'module' | 'scene', ... })` flies the camera so the whole target
150
- fits the frame, in both 3D and 2D.
151
- - **Imperative ref API** — `zoomToFit` / `frameTopDown` / `focusOn` /
152
- `setCamera` / `getCamera` /
153
- `getMoverWorldTransform` / `getBoundingBox` / `exportModel` /
154
- `exportScreenshot` / `setMoverPosition(s)` (Record or
155
- `MoverPositionEntry[]` indexed by `MoverConfig.index`) / `getMoverPosition` /
156
- `setModuleStatuses` / `clearModuleStatuses` / `getFeedSegments` /
157
- `checkMoverCollisions` / `reloadAssets`.
158
- - **Designed for scale** — the `⚡ Perf stress` demo runs three ovals × 250
159
- movers each (= 750 simultaneously animated movers) without React commits
160
- during the steady state.
161
-
162
- ---
163
-
164
- ## Gallery
165
-
166
- | | |
167
- |---|---|
168
- | ![Multi-track](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/02-multi-track.png)<br>**Multi-track placement** — two independent XTS lines composed via `trackTransform`. | ![Stations + Areas](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/03-stations-areas.png)<br>**Stations + Areas** — cleanroom / safety-area zone overlays, station tubes with stop markers. |
169
- | ![Stator heatmap](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/04-stator-heatmap.png)<br>**Stator heatmap** — vertex-colour gradient along the centerline, fed from your live drive currents. | ![Collision detection](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/05-collision.png)<br>**Sub-mm collision detection** — continuous monitor with banner; pair-wise 1D arc-length test on the shared chain. |
170
- | ![Drive status](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/06-drive-status.png)<br>**Drive status** — emissive blink at 1 Hz on the GLB itself + camera-facing 3D icons (▲ warning, ⊙ error). | ![Perf stress](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/07-perf-stress.png)<br>**Perf stress** — 750 movers animated at 60 Hz with zero React commits in steady state. |
171
- | ![Shadows + IBL](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/08-shadows.png)<br>**PCF-soft shadows + IBL** — opt-in shadows on a transparent canvas; image-based lighting on by default. | ![Top-down export](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/09-screenshot-export.png)<br>**`exportScreenshot('top-down')`** — orthographic, AABB-fit, mirrors the 2D viewer convention. |
172
- | ![Feed segments](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/10-feed-segments.png)<br>**Feed segments (Einspeisestränge)** — `feedSegmentHighlights` tints whole strands; the pink one wraps the loop seam. | |
173
-
174
- ---
175
-
176
- ## Installation
177
-
178
- ```bash
179
- npm install beckhoff-xts-viewer-3d
180
- ```
181
-
182
- Peer dependencies (you almost certainly have these already):
183
-
184
- ```bash
185
- npm install react react-dom three
186
- ```
187
-
188
- Compatibility:
189
-
190
- - React ≥ 18 (tested on 19)
191
- - Three.js ≥ 0.150
192
- - Modern bundler (Vite, Webpack, Next.js, Remix, Astro, plain CRA — all fine)
193
-
194
- The package is `"type": "module"` and ships ESM + CJS via
195
- `./dist/index.{js,cjs,d.ts}`. `sideEffects: false` so unused exports
196
- tree-shake out.
197
-
198
- ---
199
-
200
- ## Getting started
201
-
202
- ### 1. GLB assets — zero-config by default
203
-
204
- The viewer's default `assetsBaseUrl` points at the jsDelivr CDN, version-
205
- pinned to the matching [`beckhoff-xts-viewer-3d-assets`](https://www.npmjs.com/package/beckhoff-xts-viewer-3d-assets)
206
- release:
207
-
208
- ```text
209
- https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d-assets@<version>/models
210
- ```
211
-
212
- So in the typical case there is **nothing to install or host** — drop in
213
- `<XtsViewer3D config={…} />` and the GLBs stream from jsDelivr.
214
-
215
- Calibration metadata (origin-correction, path lengths, AABBs) is compiled
216
- into the JS bundle, so the viewer never makes a sidecar HTTP request for
217
- known module / mover / tool types.
218
-
219
- #### Self-hosting
220
-
221
- If you can't reach jsDelivr (air-gapped network, corporate proxy, regulated
222
- environment), install the assets package and serve the GLBs yourself:
223
-
224
- ```bash
225
- npm install beckhoff-xts-viewer-3d-assets
226
- ```
227
-
228
- Copy `node_modules/beckhoff-xts-viewer-3d-assets/models` into your
229
- app's static folder during build, and point at it:
230
-
231
- ```tsx
232
- <XtsViewer3D config={cfg} assetsBaseUrl="/models" />
233
- ```
234
-
235
- Or use any other URL prefix:
236
-
237
- ```tsx
238
- <XtsViewer3D config={cfg} assetsBaseUrl="https://cdn.example.com/xts/" />
239
- ```
240
-
241
- #### Why a separate assets package?
242
-
243
- The viewer JS bundle is ~110 kB compressed. The CAD-derived GLBs total
244
- ~28 MB. Splitting them lets `npm install beckhoff-xts-viewer-3d`
245
- stay tiny, while the assets are version-pinned and fetched on demand from
246
- a globally cached CDN.
247
-
248
- ### 2. Build a config
249
-
250
- The minimum: one `ProcessingUnitConfig` with one `Part` containing your
251
- module list and one or more `Mover`s. See the snippet at the top of this
252
- README for a working oval loop.
253
-
254
- The component fills its parent (`width: 100%; height: 100%`) — make sure
255
- the parent has a definite height. The canvas is **transparent**: whatever
256
- sits behind the host element shows through, so wrap in a styled container
257
- if you want a solid backdrop.
258
-
259
- ### 3. Drive mover positions
260
-
261
- Movers don't animate themselves — your app pushes positions through the
262
- imperative ref:
263
-
264
- ```tsx
265
- import { useRef, useEffect } from 'react';
266
- import { XtsViewer3D, type XtsViewer3DRef } from 'beckhoff-xts-viewer-3d';
267
-
268
- function App() {
269
- const viewerRef = useRef<XtsViewer3DRef>(null);
270
-
271
- useEffect(() => {
272
- let raf = 0;
273
- const tick = (t: number) => {
274
- viewerRef.current?.setMoverPosition(0, (t / 5) % 3000);
275
- raf = requestAnimationFrame(tick);
276
- };
277
- raf = requestAnimationFrame(tick);
278
- return () => cancelAnimationFrame(raf);
279
- }, []);
280
-
281
- return <XtsViewer3D ref={viewerRef} config={config} />;
282
- }
283
- ```
284
-
285
- The push goes straight into a per-component store; `<XtsMover>` reads it in
286
- `useFrame` and mutates its Three.js group. **Zero React renders per tick.**
287
-
288
- For state-driven flows the legacy contract still works: just keep
289
- `MoverConfig.partPositionMm` updated in the config and pass the new config
290
- through. Whichever you set last wins (imperative store > config prop).
291
-
292
- ### 4. Read selection + errors
293
-
294
- ```tsx
295
- <XtsViewer3D
296
- config={config}
297
- selectionMode="Single" // | 'Off' | 'Multi'
298
- onSelectionChange={(s) => console.log('selection', s)}
299
- onError={(err) => console.error(err.code, err.message)}
300
- />
301
- ```
302
-
303
- `SelectionState` carries `{ modules: ModuleRef[], movers: MoverRef[] }`.
304
- Errors flow through `onError` with typed codes:
305
- `asset-load-failed | unknown-module-type | unmatched-clothoid-half | …`.
306
-
307
- ### 5. Detect mover collisions
308
-
309
- Two flavours — pick one.
310
-
311
- **Continuous monitoring** (callback fires when the collision set changes):
312
-
313
- ```tsx
314
- import type { MoverCollision } from 'beckhoff-xts-viewer-3d';
315
-
316
- <XtsViewer3D
317
- config={config}
318
- collisionDetection={{
319
- enabled: true,
320
- warningGapMm: 0, // 0 = real collisions; > 0 also reports near-misses
321
- intervalMs: 0, // 0 = check every frame; e.g. 50 for 20 Hz
322
- onCollisionsChange: (collisions: MoverCollision[]) => {
323
- // Fires only when the set actually changes (pair appears / disappears
324
- // / penetrationMm shifts by > 0.01 mm).
325
- if (collisions.length) console.warn('crash:', collisions[0]);
326
- },
327
- }}
328
- />;
329
- ```
330
-
331
- **One-shot query** via the imperative ref:
332
-
333
- ```tsx
334
- const list = viewerRef.current?.checkMoverCollisions({ warningGapMm: 5 });
335
- // → MoverCollision[] sorted deepest-penetration first
336
- ```
337
-
338
- Each `MoverCollision` carries:
339
-
340
- ```ts
341
- {
342
- a: MoverRef; b: MoverRef;
343
- idA: string; idB: string;
344
- penetrationMm: number; // > 0 = overlap, 0 = touching, < 0 = warning gap
345
- positionAMm: number; positionBMm: number;
346
- pathLengthAMm: number; pathLengthBMm: number;
347
- viaWraparound: boolean; // true when measured across the closed-loop seam
348
- }
349
- ```
350
-
351
- Sub-millimetre accurate (pure float64 arc-length math). The check covers
352
- movers travelling on the same chain; cross-track collisions in multi-XPU
353
- setups are out of scope.
354
-
355
- ### 6. Capture screenshots
356
-
357
- Trigger from anywhere in your app via the imperative ref:
77
+ No asset hosting is required. GLBs stream from the matching
78
+ `beckhoff-xts-viewer-3d-assets` release on jsDelivr; see
79
+ [asset hosting](docs/USING-THE-COMPONENT.md#1-glb-assets--zero-config-by-default)
80
+ to self-host them instead.
81
+
82
+ ## Features
83
+
84
+ **Geometry and layout.** The full Beckhoff module and mover catalogue —
85
+ Standard AT, Eco AT2200, NCT (AT2002 / AT2102 with AT8200 tools), Hygienic ATH
86
+ and Hepco GFX2 — with straights, ±22.5° and ±45° curves and the AT2050 / ATH2050
87
+ 180° clothoid reversal. Module-to-module C0/C1 continuity is pinned by golden
88
+ fixtures. Per-XPU `trackTransform` places independent lines in one scene, and
89
+ `positionFrame` remaps every position value into the host's coordinate
90
+ convention without touching the rest of the config.
91
+
92
+ **Mover motion.** `setMoverPositions()` writes into a per-component store that
93
+ `useFrame` drains directly into the three.js scene graph, so a 60 Hz drive loop
94
+ produces no React renders. An instanced fast path batches mover bodies into a
95
+ single draw call where per-mover scene nodes are not needed.
96
+
97
+ **Rendering.** ACES filmic tone mapping, image-based lighting from a
98
+ procedurally built indoor environment, anisotropic filtering and optional
99
+ PCF-soft shadows, all switchable through `display.*`. SSAO and bloom are
100
+ available when the optional post-processing peers are installed.
101
+
102
+ **Interaction and status.** Click selection for modules and movers, tinting the
103
+ GLB itself rather than overlaying wireframes; drive-status blink plus
104
+ camera-facing warning and error icons; feed-segment (Einspeisestrang) tinting
105
+ that wraps the seam of a closed loop; stations, areas, dimensions and info bars;
106
+ stop-position ghost movers; and a stator heatmap driven by
107
+ `(positionMm, value)` samples.
108
+
109
+ **Measuring and annotating.** CAD-style tools — point-to-point distance with
110
+ optional ΔX/ΔY/ΔZ guides, angle, polyline, and distance _along_ the XTS path —
111
+ picked in the scene with snapping to geometry corners and edges as well as to
112
+ mover centres, module boundaries, station stops and the track path itself.
113
+ Annotations pin a label anywhere, including to a mover, where they follow it
114
+ while it drives. Everything works the same in the 2D plan view, renders into
115
+ screenshots and video frames, and is fully drivable from
116
+ `viewerRef.current.measurements`.
117
+
118
+ **Analysis.** Sub-millimetre mover collision detection, as a one-shot call or a
119
+ continuous monitor, plus module-level collision probes.
120
+
121
+ **Export.** `exportScreenshot()` renders offscreen at any resolution with MSAA,
122
+ in current-camera, top-down or saved-camera mode. `beginFrameCapture()` opens a
123
+ reusable session whose `grab()` returns a frame synchronously without image
124
+ encoding, and keeps producing frames while the window is minimised. Both bypass
125
+ the post-processing chain; the result reports whether that happened.
126
+
127
+ **Camera.** Orthographic top-down projection for a live 2D plan view, an
128
+ animated `focusOn()` for stations, areas, movers, modules or the whole scene,
129
+ and an opt-in CAD ViewCube.
130
+
131
+ ### Measurements and annotations
358
132
 
359
133
  ```tsx
360
134
  const viewer = useRef<XtsViewer3DRef>(null);
361
-
362
- async function saveTopDown() {
363
- const result = await viewer.current!.exportScreenshot({
364
- mode: 'top-down', // | 'current' | 'custom'
365
- pixelRatio: 2, // 2× sharpness even on a 1× display
366
- paddingFactor: 1.15,
367
- format: 'png', // | 'jpeg' | 'webp'
368
- backgroundColor: null, // null = transparent PNG; '#0e1116' for a solid bg
369
- });
370
- // result: { blob, widthPx, heightPx, camera, boundingBoxMm, mode }
371
- saveAs(result.blob, `layout-${Date.now()}.png`);
372
- }
373
- ```
374
-
375
- `'top-down'` mirrors the 2D viewer: orthographic camera centred on the
376
- scene's bounding box, world +X = image right, world +Y = image up.
377
- `'custom'` lets you reproduce a saved framing exactly:
378
-
379
- ```tsx
380
- viewer.current!.exportScreenshot({
381
- mode: 'custom',
382
- camera: previousResult.camera, // round-trip a saved CameraState
383
- });
384
- ```
385
-
386
- Renders go to an offscreen WebGLRenderTarget — the live canvas keeps
387
- running at full speed, no `preserveDrawingBuffer` perf cost.
388
-
389
- ### 7. Live 2D plan view (orthographic top-down)
390
-
391
- Set `projection="orthographic"` to switch the **live** canvas into a flat 2D
392
- plan view — straight down +Z, world +Y up — that is pixel-consistent with
393
- `exportScreenshot({ mode: 'top-down' })`. Switching at runtime does **not**
394
- recreate the WebGL context, so toggling between 3D and 2D is instant.
395
-
396
- ```tsx
397
- const [is2D, setIs2D] = useState(false);
135
+ const [tool, setTool] = useState<MeasurementTool>('none');
398
136
 
399
137
  <XtsViewer3D
138
+ ref={viewer}
400
139
  config={config}
401
- projection={is2D ? 'orthographic' : 'perspective'}
402
- // shadows add nothing to a flat plan — drop them in 2D
403
- display={is2D ? { ...display, shadows: false } : display}
404
- />
405
- ```
406
-
407
- In orthographic top-down, **rotation is auto-disabled** (a 2D plan has no
408
- meaningful orbit); pan and zoom stay on. Set `lock={{ rotate: false }}` to
409
- opt rotation back in. The frustum re-fits automatically on container resize
410
- and whenever the scene's bounding box changes. From the ref, `frameTopDown()`
411
- re-fits on demand and `zoomToFit()` adjusts the ortho frustum (instead of
412
- dollying) when the live camera is orthographic.
413
-
414
- ### 8. Fly the camera to an object (focusOn)
415
-
416
- `viewerRef.current.focusOn(target, opts)` animates the camera so a **station,
417
- area, mover, module** — or the whole `scene` — fits the frame. In perspective
418
- the current view angle is preserved (the camera only dollies + re-centres); in
419
- orthographic top-down the frustum re-frames and the camera pans straight over
420
- the target. Works in both 2D and 3D.
421
-
422
- ```tsx
423
- // Frame a station, 700 ms ease-in-out (defaults)
424
- viewerRef.current?.focusOn({ kind: 'station', stationId: 3 });
425
-
426
- // Frame a mover, faster
427
- viewerRef.current?.focusOn(
428
- { kind: 'mover', ref: { processingUnitObjectId: 0, moverIndex: 2 } },
429
- { durationMs: 500 },
430
- );
431
-
432
- // Frame an area / a module / the whole scene
433
- viewerRef.current?.focusOn({ kind: 'area', areaId: 1 });
434
- viewerRef.current?.focusOn({
435
- kind: 'module',
436
- ref: { processingUnitObjectId: 0, partObjectId: 10, moduleIndex: 4 },
437
- });
438
- viewerRef.current?.focusOn({ kind: 'scene' }, { durationMs: 0 }); // 0 = jump
439
- ```
440
-
441
- `FocusOptions`: `durationMs` (default `700`, `0` jumps), `paddingFactor`
442
- (default `1.2` perspective / `1.1` ortho), `easing` (`'easeInOutCubic'` |
443
- `'linear'`). A new `focusOn` call supersedes any in-flight animation.
444
-
445
- ### 9. Mark zones with Areas
446
-
447
- Areas are stop-position-free range overlays — like Stations, but with no
448
- markers. Use them for cleanroom / safety-area / manual-access
449
- zones that don't drive any mover behaviour:
450
-
451
- ```tsx
452
- <XtsViewer3D
453
- config={{
454
- processingUnits: [/* … */],
455
- areas: [
456
- {
457
- areaId: 1,
458
- description: 'Cleanroom',
459
- isEnabled: true,
460
- partOids: [0],
461
- startPositionOnPart: 250,
462
- endPositionOnPart: 1000,
463
- color: 0xff_4d_9d_e0, // ARGB
464
- },
465
- ],
466
- }}
467
- display={{
468
- areaOptions: {
469
- thicknessMm: 8,
470
- displacementMm: 60,
471
- opacity: 0.7,
472
- showAreaDescription: true,
473
- },
140
+ // The same tools work in the 3D view and the 2D plan view.
141
+ projection={plan2D ? 'orthographic' : 'perspective'}
142
+ measurement={{
143
+ tool, // 'distance' | 'angle' | 'path' | 'trackDistance' | 'annotation'
144
+ snap: { enabled: true, radiusPx: 14 },
145
+ onMeasurementCreate: (m, result) => console.log(m.kind, result.text),
474
146
  }}
475
147
  />;
476
- ```
477
-
478
- ### 10. Stator heatmap
479
-
480
- Coloured tube along each part's centerline, vertex colours interpolated
481
- across consumer-supplied `(positionMm, value)` samples — perfect for
482
- streaming live drive currents or stator temperatures:
483
-
484
- ```tsx
485
- import type { StatorHeatmap } from 'beckhoff-xts-viewer-3d';
486
-
487
- const heatmap: StatorHeatmap = {
488
- parts: [{ partOid: 0, samples: [{ positionMm: 0, value: 25 }, /* … */] }],
489
- min: 0, max: 100,
490
- minColor: '#22c55e', // default green
491
- maxColor: '#ef4444', // default red
492
- };
493
-
494
- <XtsViewer3D
495
- config={config}
496
- statorHeatmap={heatmap}
497
- display={{ showStatorHeatmap: true }}
498
- />;
499
- ```
500
-
501
- ### 11. Track direction + zero offset
502
-
503
- When the host machine uses a different sign convention or zero point
504
- than the GLB chain, set a `positionFrame` on the XPU. Movers, stations,
505
- areas, stops, ghosts, and `getMoverWorldTransform` all follow:
506
148
 
507
- ```tsx
508
- processingUnits: [
509
- {
510
- objectId: 0,
511
- moverType: 'AT9014_0055',
512
- positionFrame: { direction: 'negative', originMm: 1500 },
513
- parts: [/* */],
514
- movers: [
515
- { index: 0, id: 0, partOid: 0, partPositionMm: 0 },
516
- ],
517
- },
518
- ]
149
+ // …or place and read measurements without any user interaction:
150
+ viewer.current.measurements.add({
151
+ kind: 'distance',
152
+ points: [{ positionMm: [0, 0, 200] }, { positionMm: [1000, 0, 200] }],
153
+ });
154
+ viewer.current.measurements.results(); // [{ value: 1000, text: '1 000.0 mm', }]
155
+ const saved = viewer.current.measurements.toJSON(); // persist, restore with fromJSON()
519
156
  ```
520
157
 
521
- For more every prop, every ref method, every helper, every type —
522
- read **[docs/USING-THE-COMPONENT.md](docs/USING-THE-COMPONENT.md)**.
523
-
524
- ---
525
-
526
- ## Realism + performance
527
-
528
- The viewer is tuned to look like a CAD-quality render out of the box while
529
- holding 60 Hz on mid-range integrated GPUs. Every realism feature is
530
- opt-out via `display.*` so consumers who liked the old direct-lighting
531
- look can revert with a single prop.
158
+ See [Measurements + annotations](docs/USING-THE-COMPONENT.md#17-measurements--annotations)
159
+ for the tools, the snap kinds and the full API.
532
160
 
533
- | Feature | Default | Knob | Cost |
534
- |---|---|---|---|
535
- | 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 |
536
- | Environment intensity | `0.4` | `display.environmentIntensity` | — |
537
- | ACES Filmic tone mapping | **on** | `display.toneMapping` (`'aces' \| 'linear' \| 'reinhard' \| 'cineon' \| 'agx' \| 'none'`) | shader-side, ~free |
538
- | Tone-mapping exposure | `1.0` | `display.toneMappingExposure` | — |
539
- | Anisotropic texture filtering (max hardware) | **on** | (always on; pure sampler state) | none |
540
- | `castShadow` / `receiveShadow` on every GLB mesh | **on** | (auto) | none unless shadows are enabled |
541
- | PCF-soft shadows on the directional light | **off** | `display.shadows` | one extra render pass on the shadow map (4096²) |
542
- | Shadow-catcher plane (transparent ground) | tied to `shadows` | — | trivial |
543
- | Hemisphere fill (when env lighting is off) | **on (when no IBL)** | (auto) | vertex-frequency |
544
- | Auto-pause render loop when tab hidden | **on** | `performance.autoPauseOnHidden` | — |
545
- | Mover updates via `MoverPositionStore` (no React commits) | **always** | — | — |
546
-
547
- The PBR pipeline picks up automatically: any `MeshStandardMaterial` /
548
- `MeshPhysicalMaterial` already exported in your GLBs (the standard glTF
549
- metal/rough workflow) inherits `scene.environment` for reflections.
550
- Custom GLBs with non-PBR materials are unaffected — they render exactly
551
- the same.
552
-
553
- If you need flat direct-lighting parity:
554
-
555
- ```tsx
556
- <XtsViewer3D
557
- config={config}
558
- display={{
559
- environmentLighting: false, // disable IBL
560
- toneMapping: 'none', // no tone-mapping curve
561
- }}
562
- />
563
- ```
564
-
565
- Stress baseline: `⚡ Perf stress` (3 ovals × 250 movers = 750 mover groups
566
- animated at 60 Hz) runs steady at ~16 ms/frame in Chrome on a mid-range
567
- laptop with 0 React commits in steady state, IBL + ACES + anisotropy on.
161
+ ## Gallery
568
162
 
569
- ---
163
+ | | |
164
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
165
+ | ![Multi-track](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/02-multi-track.png)<br>Two independent lines composed with `trackTransform`. | ![Stations and areas](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/03-stations-areas.png)<br>Station tubes with stop markers, and zone overlays for cleanroom or safety areas. |
166
+ | ![Stator heatmap](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/04-stator-heatmap.png)<br>Vertex-colour gradient along the centerline, fed from live drive currents. | ![Collision detection](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/05-collision.png)<br>Continuous pair-wise arc-length collision monitoring. |
167
+ | ![Drive status](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/06-drive-status.png)<br>Emissive blink on the GLB plus camera-facing warning and error icons. | ![Many movers](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/07-perf-stress.png)<br>750 movers animated at 60 Hz with no React commits in steady state. |
168
+ | ![Shadows and IBL](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/08-shadows.png)<br>Opt-in PCF-soft shadows on a transparent canvas, image-based lighting on by default. | ![Top-down export](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/09-screenshot-export.png)<br>`exportScreenshot({ mode: 'top-down' })` — orthographic and AABB-fit. |
169
+ | ![Feed segments](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/10-feed-segments.png)<br>`feedSegmentHighlights` tints whole electrical strands; the pink one wraps the loop seam. | |
570
170
 
571
171
  ## Troubleshooting
572
172
 
573
- ### Modules render as yellow boxes, movers as blue boxes
173
+ ### Modules render as yellow boxes and movers as blue boxes
574
174
 
575
- **Symptom.** GLBs never appear; the viewer shows wireframe placeholders
576
- (modules in `#FFB000`, movers in `#3D88E0`) and the browser console
577
- prints `THREE.WARNING: Multiple instances of Three.js being imported`.
578
- No `models/*.glb` requests show up in the Network panel.
175
+ The viewer is showing wireframe placeholders because no GLB was accepted. The
176
+ console also reports `THREE.WARNING: Multiple instances of Three.js being
177
+ imported`, and no `models/*.glb` requests appear in the network panel.
579
178
 
580
- **Cause.** A transitive dep (`stats-gl`, via `@react-three/drei`) pins
581
- `three` in its own `dependencies`, so npm installs a second
582
- `three`-copy under `node_modules/stats-gl/node_modules/three`. The two
583
- copies produce two `THREE.*` namespaces; `useGLTF`'s `instanceof`
584
- checks fail across the boundary and silently reject every parsed scene.
179
+ This happens when a transitive dependency pins its own copy of `three`. Two
180
+ copies mean two `THREE.*` namespaces, and the `instanceof` checks inside
181
+ `useGLTF` reject every parsed scene across that boundary.
585
182
 
586
- **Fix.** Force your bundler to deduplicate `three`. For Vite, add
587
- `resolve.dedupe`:
183
+ Deduplicate `three` in your bundler:
588
184
 
589
185
  ```ts
590
186
  // vite.config.ts
@@ -594,167 +190,88 @@ export default defineConfig({
594
190
  });
595
191
  ```
596
192
 
597
- Webpack / Next.js: alias `three` to your root `node_modules/three`. See
598
- [USING-THE-COMPONENT.md § Bundler configuration](docs/USING-THE-COMPONENT.md#bundler-configuration--deduplicate-three)
599
- for the full snippets and how to bust Vite's pre-bundle cache after the
600
- change.
601
-
602
- ---
193
+ For Webpack and Next.js, alias `three` to your root `node_modules/three`.
194
+ [Bundler configuration](docs/USING-THE-COMPONENT.md#bundler-configuration--deduplicate-three)
195
+ has the full snippets and explains how to clear Vite's pre-bundle cache
196
+ afterwards.
603
197
 
604
198
  ## Documentation
605
199
 
606
- - **[docs/USING-THE-COMPONENT.md](docs/USING-THE-COMPONENT.md)**
607
- consumer guide: install, asset hosting, every prop, every ref method,
608
- recipes, performance tuning.
609
- - **[docs/ADDING-A-MODULE.md](docs/ADDING-A-MODULE.md)** —
610
- developer guide: add a new module / mover / tool type from STP to
611
- calibrated GLB.
612
- - **[docs/RELEASING.md](docs/RELEASING.md)**
613
- one-click release flow: how to publish a new version, npm Trusted
614
- Publishers setup, calibration safety, emergency manual release.
615
- - **[docs/screenshots/README.md](docs/screenshots/README.md)** —
616
- how to refresh the README + docs gallery from the playground.
617
-
618
- ---
200
+ - [Using the component](docs/USING-THE-COMPONENT.md) — every prop, every ref
201
+ method, asset hosting, recipes and performance tuning.
202
+ - [Adding a module](docs/ADDING-A-MODULE.md) — taking a new module, mover or
203
+ tool type from STP file to calibrated GLB.
204
+ - [Performance](docs/PERFORMANCE.md) the asset compression pipeline and the
205
+ runtime budget.
206
+ - [Releasing](docs/RELEASING.md) — the automated publish flow.
619
207
 
620
- ## Development setup
208
+ ## Development
621
209
 
622
- This repo is a pnpm workspace `pnpm-lock.yaml` is the source of
623
- truth and `npm ci` / `npm install` will not work. Get pnpm via
624
- `npm install -g pnpm@10` (or any other installer), then:
210
+ The repo is a pnpm workspace; `pnpm-lock.yaml` is the source of truth, so
211
+ `npm install` will not work. Install pnpm 10, then:
625
212
 
626
213
  ```bash
627
214
  pnpm install
628
- pnpm test # 310 unit + property tests
215
+ pnpm lint
629
216
  pnpm typecheck
217
+ pnpm test
218
+ pnpm run test:coverage
219
+ pnpm build
630
220
  ```
631
221
 
632
- ### Run the playground
633
-
634
- The playground is a small Vite app at `playground/` that demonstrates every
635
- feature — selection, calibration, composer, multi-track, shadows, ViewCube,
636
- drive-status icons, the perf stress test, IBL toggle, intensity slider.
637
-
638
- ```bash
639
- pnpm dev # http://127.0.0.1:5173
640
- ```
641
-
642
- Sidebar controls let you switch demos, animate movers, toggle shadows /
643
- IBL / ViewCube / theme, drag-and-drop tracks together in the composer,
644
- and live-edit calibration overrides.
645
-
646
- ### Build the library
647
-
648
- ```bash
649
- pnpm build # → dist/
650
- pnpm playground:build
651
- ```
222
+ `pnpm dev` starts the playground at `http://127.0.0.1:5173`. It exercises every
223
+ feature and lets you switch demos, drive movers, toggle lighting and shadows,
224
+ compose multi-track layouts and live-edit calibration overrides.
652
225
 
653
226
  ### Asset pipeline
654
227
 
655
- CAD source files (`stepfiles/*.stp`) are converted to runtime-ready GLBs
656
- and per-asset JSON sidecars. To regenerate after touching a STP:
228
+ CAD sources in `stepfiles/*.stp` are converted to runtime GLBs plus per-asset
229
+ JSON sidecars:
657
230
 
658
231
  ```bash
659
- pnpm assets:convert # STP → GLB via occt-import-js
232
+ pnpm assets:convert # STP → GLB
660
233
  pnpm assets:inspect # refresh docs/data/glb-inspection.json
661
- pnpm assets:generate-sidecars # module .meta.json (origin-correction)
234
+ pnpm assets:generate-sidecars # module .meta.json origin corrections
662
235
  pnpm assets:generate-mover-sidecars
663
236
  ```
664
237
 
665
- The sidecar generators are idempotent: existing files are skipped so they
666
- never stomp hand-tuned calibration values. Pass `--force` to regenerate.
667
-
668
- The release pipeline **never** invokes the generators — `bundle-sidecars`
669
- reads existing JSONs read-only into the JS bundle. See
670
- [`docs/RELEASING.md`](docs/RELEASING.md#calibration-safety-the-metajson-story).
671
-
672
- To add a new module / mover / tool type from scratch — naming convention,
673
- type registration, sidecar generation, calibration workflow — follow
674
- **[docs/ADDING-A-MODULE.md](docs/ADDING-A-MODULE.md)**.
675
-
676
- ### Project layout
677
-
678
- ```
679
- .
680
- ├── src/ Library source — published as the npm package
681
- │ ├── components/ <XtsViewer3D> + internal scene tree
682
- │ ├── geometry/ Path math, ChainBuilder, normalizeXtsConfig, …
683
- │ ├── assets/ AssetManifest, SidecarLoader, AssetLoader
684
- │ └── interaction/ SelectionManager
685
- ├── packages/
686
- │ └── assets/ Sibling npm package (GLB-only mirror)
687
- ├── playground/ Vite app exercising every feature
688
- ├── public/models/ GLBs + .meta.json sidecars (sources)
689
- ├── stepfiles/ Source CAD STP files (NOT shipped)
690
- ├── scripts/ Asset pipeline + version-sync utilities
691
- ├── docs/
692
- │ ├── USING-THE-COMPONENT.md Consumer guide (props, ref API, recipes)
693
- │ ├── ADDING-A-MODULE.md How to register a new module / mover / tool
694
- │ ├── RELEASING.md How to publish a new release
695
- │ ├── screenshots/ README + docs gallery
696
- │ └── data/ GLB AABB inspection JSON
697
- ├── .github/workflows/ release.yml + release-assets.yml + deploy-docs.yml
698
- └── README.md
699
- ```
700
-
701
- ### Tests
238
+ The generators skip existing files so hand-tuned calibration is never
239
+ overwritten; pass `--force` to regenerate. The release pipeline never runs them
240
+ and reads the sidecars read-only.
702
241
 
703
- `vitest` covers path math, normalize-config, chain-building, sample helpers,
704
- selection logic, asset URL composition, the composer reducer, dimension
705
- ticks, and the multi-track transform composition. Run focused:
706
-
707
- ```bash
708
- pnpm vitest run src/geometry/__tests__/ChainBuilder.test.ts
709
- ```
710
-
711
- ---
712
-
713
- ## Releasing
714
-
715
- The pipeline is **fully automated and triggered by every push to
716
- `main`**. [semantic-release](https://semantic-release.gitbook.io/) reads
717
- your Conventional Commit messages, decides the next version, writes
718
- `CHANGELOG.md`, publishes both packages to npm, and opens a GitHub
719
- Release.
242
+ ### Layout
720
243
 
721
244
  ```text
722
- git push origin main # commits like `fix: …`, `feat: …`, `feat!: …`
723
-
724
- .github/workflows/release.yml
725
-
726
- npx semantic-release # version bump + npm publish + GH release
245
+ src/ Library source this is what ships
246
+ components/ <XtsViewer3D> and the internal scene tree
247
+ geometry/ Path math, ChainBuilder, normalizeXtsConfig
248
+ assets/ AssetManifest, AssetLoader, SidecarLoader
249
+ interaction/ SelectionManager
250
+ packages/assets/ Sibling npm package: GLB-only mirror
251
+ playground/ Vite app exercising every feature
252
+ public/models/ GLBs and .meta.json calibration sidecars
253
+ stepfiles/ Source CAD files (not published)
254
+ scripts/ Asset pipeline and version-sync utilities
255
+ docs/ VitePress site and guides
727
256
  ```
728
257
 
729
- You **never** call `npm version`, write a changelog, create a tag, or
730
- run `npm publish` by hand. Commit-type → bump:
258
+ ### Releasing
731
259
 
732
- | Prefix | Bump |
733
- |---|---|
734
- | `fix:` / `perf:` | patch |
735
- | `feat:` | minor |
736
- | `feat!:` / `fix!:` / `BREAKING CHANGE:` footer | major |
737
- | `chore:` / `docs:` / `ci:` / `refactor:` / `test:` | none (no release) |
260
+ Every push to `main` runs [semantic-release](https://semantic-release.gitbook.io/),
261
+ which derives the version from Conventional Commit messages, writes
262
+ `CHANGELOG.md` and publishes both packages. Never run `npm version`, edit the
263
+ changelog or publish by hand.
738
264
 
739
- Plus a SHA-256 guard around `public/models/*.meta.json` so hand-tuned
740
- calibration values can never be overwritten by the pipeline.
265
+ | Prefix | Bump |
266
+ | -------------------------------------------------- | ----- |
267
+ | `fix:` / `perf:` | patch |
268
+ | `feat:` | minor |
269
+ | `feat!:` / `fix!:` / `BREAKING CHANGE:` footer | major |
270
+ | `chore:` / `docs:` / `ci:` / `refactor:` / `test:` | none |
741
271
 
742
- Preview the next release locally:
743
-
744
- ```bash
745
- pnpm release:dry-run
746
- ```
747
-
748
- See **[docs/RELEASING.md](docs/RELEASING.md)** for the full guide:
749
- plugin order, calibration safety, Trusted Publishers graduation, and
750
- emergency manual flow.
751
-
752
- ---
272
+ Preview with `pnpm release:dry-run`. [Releasing](docs/RELEASING.md) covers the
273
+ plugin chain, the calibration-safety guard and the manual fallback.
753
274
 
754
275
  ## License
755
276
 
756
277
  MIT — see [LICENSE](LICENSE).
757
-
758
- The Beckhoff CAD source files under `stepfiles/` are included for
759
- verification only and remain subject to their respective Beckhoff
760
- licensing terms — they are not shipped with the published npm package.