npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

beckhoff-xts-viewer-3d

npm npm assets License: MIT Release

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

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

  • 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 refviewerRef.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 moversdisplay.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.
  • ScreenshotsviewerRef.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 viewprojection="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 focusviewerRef.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 APIzoomToFit / 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-trackMulti-track placement — two independent XTS lines composed via trackTransform. | Stations + AreasStations + Areas — cleanroom / safety-area zone overlays, station tubes with stop markers. | | Stator heatmapStator heatmap — vertex-colour gradient along the centerline, fed from your live drive currents. | Collision detectionSub-mm collision detection — continuous monitor with banner; pair-wise 1D arc-length test on the shared chain. | | Drive statusDrive status — emissive blink at 1 Hz on the GLB itself + camera-facing 3D icons (▲ warning, ⊙ error). | Perf stressPerf stress — 750 movers animated at 60 Hz with zero React commits in steady state. | | Shadows + IBLPCF-soft shadows + IBL — opt-in shadows on a transparent canvas; image-based lighting on by default. | Top-down exportexportScreenshot('top-down') — orthographic, AABB-fit, mirrors the 2D viewer convention. |


Installation

npm install beckhoff-xts-viewer-3d

Peer dependencies (you almost certainly have these already):

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

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:

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:

<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 first

Each 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 = 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:

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

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

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:

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.

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

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

Releasing

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

pnpm release:dry-run

See 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.