# beckhoff-xts-viewer-3d

[![npm](https://img.shields.io/npm/v/beckhoff-xts-viewer-3d.svg)](https://www.npmjs.com/package/beckhoff-xts-viewer-3d)
[![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)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![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)

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.

![Oval loop demo](https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d/docs/screenshots/01-oval-loop.png)

```tsx
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](#highlights)
- [Gallery](#gallery)
- [Installation](#installation)
- [Getting started](#getting-started)
  - [GLB assets — zero-config by default](#1-glb-assets--zero-config-by-default)
  - [Build a config](#2-build-a-config)
  - [Drive mover positions](#3-drive-mover-positions)
  - [Read selection + errors](#4-read-selection--errors)
  - [Detect mover collisions](#5-detect-mover-collisions)
  - [Capture screenshots](#6-capture-screenshots)
  - [Live 2D plan view (orthographic top-down)](#7-live-2d-plan-view-orthographic-top-down)
  - [Fly the camera to an object (focusOn)](#8-fly-the-camera-to-an-object-focuson)
  - [Mark zones with Areas](#9-mark-zones-with-areas)
  - [Stator heatmap](#10-stator-heatmap)
  - [Track direction + zero offset](#11-track-direction--zero-offset)
- [Realism + performance](#realism--performance)
- [Troubleshooting](#troubleshooting)
- [Documentation](#documentation)
- [Development setup](#development-setup)
- [Releasing](#releasing)
- [License](#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 via `display.*` 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.showStopPositionMovers` renders 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 saved `CameraState`. Returns a Blob plus camera state +
  bounding box for reproducible exports.
- **Track position frame** — per-XPU `positionFrame: { direction, originMm }`
  remaps every `partPositionMm`-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 or
  `MoverPositionEntry[]` indexed by `MoverConfig.index`) / `getMoverPosition` /
  `setModuleStatuses` / `clearModuleStatuses` / `checkMoverCollisions` /
  `reloadAssets`.
- **Designed for scale** — the `⚡ Perf stress` demo runs three ovals × 250
  movers each (= 750 simultaneously animated movers) without React commits
  during the steady state.

---

## Gallery

|   |   |
|---|---|
| ![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. |
| ![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. |
| ![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. |
| ![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. |

---

## Installation

```bash
npm install beckhoff-xts-viewer-3d
```

Peer dependencies (you almost certainly have these already):

```bash
npm install react react-dom three
```

Compatibility:

- 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`](https://www.npmjs.com/package/beckhoff-xts-viewer-3d-assets)
release:

```text
https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d-assets@<version>/models
```

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

```bash
npm install beckhoff-xts-viewer-3d-assets
```

Copy `node_modules/beckhoff-xts-viewer-3d-assets/models` into your
app's static folder during build, and point at it:

```tsx
<XtsViewer3D config={cfg} assetsBaseUrl="/models" />
```

Or use any other URL prefix:

```tsx
<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 `Mover`s. 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:

```tsx
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

```tsx
<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):

```tsx
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:

```tsx
const list = viewerRef.current?.checkMoverCollisions({ warningGapMm: 5 });
// → MoverCollision[] sorted deepest-penetration first
```

Each `MoverCollision` carries:

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

```tsx
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:

```tsx
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.

```tsx
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.

```tsx
// 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 = jump
```

`FocusOptions`: `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:

```tsx
<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:

```tsx
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:

```tsx
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](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:

```tsx
<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`:

```ts
// 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](docs/USING-THE-COMPONENT.md#bundler-configuration--deduplicate-three)
for the full snippets and how to bust Vite's pre-bundle cache after the
change.

---

## Documentation

- **[docs/USING-THE-COMPONENT.md](docs/USING-THE-COMPONENT.md)** —
  consumer guide: install, asset hosting, every prop, every ref method,
  recipes, performance tuning.
- **[docs/ADDING-A-MODULE.md](docs/ADDING-A-MODULE.md)** —
  developer guide: add a new module / mover / tool type from STP to
  calibrated GLB.
- **[docs/RELEASING.md](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](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:

```bash
pnpm install
pnpm test              # 310 unit + property tests
pnpm typecheck
```

### Run 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.

```bash
pnpm dev               # http://127.0.0.1:5173
```

Sidebar 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

```bash
pnpm build             # → dist/
pnpm playground:build
```

### Asset pipeline

CAD source files (`stepfiles/*.stp`) are converted to runtime-ready GLBs
and per-asset JSON sidecars. To regenerate after touching a STP:

```bash
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-sidecars
```

The 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`](docs/RELEASING.md#calibration-safety-the-metajson-story).

To add a new module / mover / tool type from scratch — naming convention,
type registration, sidecar generation, calibration workflow — follow
**[docs/ADDING-A-MODULE.md](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.md
```

### Tests

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

```bash
pnpm vitest run src/geometry/__tests__/ChainBuilder.test.ts
```

---

## Releasing

The pipeline is **fully automated and triggered by every push to
`main`**. [semantic-release](https://semantic-release.gitbook.io/) reads
your Conventional Commit messages, decides the next version, writes
`CHANGELOG.md`, publishes both packages to npm, and opens a GitHub
Release.

```text
git push origin main      # commits like `fix: …`, `feat: …`, `feat!: …`
        ↓
.github/workflows/release.yml
        ↓
npx semantic-release      # version bump + npm publish + GH release
```

You **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:

```bash
pnpm release:dry-run
```

See **[docs/RELEASING.md](docs/RELEASING.md)** for the full guide:
plugin order, calibration safety, Trusted Publishers graduation, and
emergency manual flow.

---

## License

MIT — see [LICENSE](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.
