beckhoff-xts-viewer-3d 5.2.3 → 6.0.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/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
+ import { Vector3, ColorRepresentation, Matrix4, Shape, Curve, CurvePath, WebGLRenderer, Object3D } from 'three';
1
2
  import * as React from 'react';
2
3
  import React__default from 'react';
3
- import { Vector3, ColorRepresentation, Matrix4, Shape, Curve, CurvePath, WebGLRenderer, Object3D } from 'three';
4
4
  import { GLTF } from 'three-stdlib';
5
5
 
6
6
  /**
@@ -12,7 +12,7 @@ import { GLTF } from 'three-stdlib';
12
12
  * Run `npm run sync-version` (or any script that depends on it) to refresh
13
13
  * after bumping the package version.
14
14
  */
15
- declare const VERSION: "5.2.3";
15
+ declare const VERSION: "6.0.0";
16
16
 
17
17
  /**
18
18
  * Default CDN URL for the GLB asset bundle (`beckhoff-xts-viewer-3d-assets`).
@@ -32,7 +32,18 @@ declare const VERSION: "5.2.3";
32
32
  * build time so every install streams from a known-compatible GLB
33
33
  * release.
34
34
  */
35
- declare const JSDELIVR_ASSETS_BASE_URL: "https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d-assets@2.0.1/models";
35
+ declare const JSDELIVR_ASSETS_BASE_URL: "https://cdn.jsdelivr.net/npm/beckhoff-xts-viewer-3d-assets@3.0.0/models";
36
+
37
+ /**
38
+ * Scalar tuples shared by every other type module.
39
+ *
40
+ * The leaf of the type graph: `Vec3` is a position or an Euler triple in
41
+ * millimetres / degrees, `Vec2` a viewport or margin pair in pixels. Kept
42
+ * apart from the layout types so `display`, `annotations` and `config` can
43
+ * all reach them without importing each other.
44
+ */
45
+ type Vec3 = [number, number, number];
46
+ type Vec2 = [number, number];
36
47
 
37
48
  /**
38
49
  * Catalogue taxonomy — the string unions naming every module, mover, tool and
@@ -44,7 +55,7 @@ declare const JSDELIVR_ASSETS_BASE_URL: "https://cdn.jsdelivr.net/npm/beckhoff-x
44
55
  * public type surface. Nothing here imports anything.
45
56
  */
46
57
  /** Beckhoff XTS module types supported by the 3D viewer. */
47
- type ModuleType3D = 'AT2000_0250' | 'AT2001_0250' | 'AT2002_0250' | 'AT2002_0249' | 'AT2002_0249_ZX2002_0001' | 'AT2000_0233' | 'AT2000_0249' | 'AT2100_0250' | 'AT2102_0250' | 'AT2020_0250' | 'AT2021_0250' | 'AT2025_0250' | 'AT2026_0250' | 'AT2040_0250' | 'AT2041_0250' | 'AT2042_0250' | 'AT2140_0250' | 'AT2050_0500' | 'AT2050_0501' | 'AT2050_0500_180' | 'AT2200_0500' | 'AT2202_0500' | 'ATH2000_0250' | 'ATH2001_0250' | 'ATH2002_0250' | 'ATH2020_0250' | 'ATH2040_0250' | 'ATH2041_0250' | 'ATH2042_0250' | 'ATH2050_0500' | 'ATH2050_0501' | 'ATH2050_0500_180';
58
+ type ModuleType3D = 'AT2000_0250' | 'AT2001_0250' | 'AT2002_0250' | 'AT2002_0249' | 'AT2000_0233' | 'AT2000_0249' | 'AT2100_0250' | 'AT2102_0250' | 'AT2020_0250' | 'AT2021_0250' | 'AT2025_0250' | 'AT2026_0250' | 'AT2040_0250' | 'AT2041_0250' | 'AT2050_0500' | 'AT2050_0501' | 'AT2050_0500_180' | 'AT2200_0500' | 'AT2202_0500' | 'ATH2000_0250' | 'ATH2001_0250' | 'ATH2002_0250' | 'ATH2020_0250' | 'ATH2040_0250' | 'ATH2041_0250' | 'ATH2042_0250' | 'ATH2050_0500' | 'ATH2050_0501' | 'ATH2050_0500_180';
48
59
  /**
49
60
  * Rail system beneath the module. Only affects the visual mesh
50
61
  * (Beckhoff aluminium rail vs. Hepco GFX), not the path math.
@@ -65,7 +76,20 @@ type RailSystem = 'Beckhoff' | 'HepcoGfx';
65
76
  * The `_S25` carrier ships without a magnet plate; the `_C25/_C34/_M34/_M40`
66
77
  * variants include the Beckhoff magnet plate set (`7P`/`10P` = 7/10-pole).
67
78
  */
68
- type MoverType3D = 'AT9011_0050' | 'AT9011_0070' | 'AT9012_0050' | 'AT9014_0055' | 'AT9014_0070' | 'ATH9011_0075' | 'ATH9013_0075' | 'Hepco_GFX2_1TC_S25' | 'Hepco_GFX2_1TC_C25' | 'Hepco_GFX2_1TC_C34' | 'Hepco_GFX2_1TC_M34' | 'Hepco_GFX2_1TC_M34_7P' | 'Hepco_GFX2_FCC_C25' | 'Hepco_GFX2_FCC_M34' | 'Hepco_GFX2_FCC_M34_7P' | 'Hepco_GFX2_FCC_M34_10P' | 'Hepco_GFX2_FCC_M40_10P' | 'Custom';
79
+ type MoverType3D = 'AT9014_0055' | 'AT9014_0070' | 'ATH9011_0075' | 'Hepco_GFX2_1TC_S25' | 'Hepco_GFX2_1TC_C25' | 'Hepco_GFX2_1TC_C34' | 'Hepco_GFX2_1TC_M34' | 'Hepco_GFX2_FCC_C25' | 'Hepco_GFX2_FCC_M34' | 'Hepco_GFX2_FCC_M34_7P' | 'Hepco_GFX2_FCC_M34_10P' | 'Hepco_GFX2_FCC_M40_10P' | 'Custom';
80
+ /**
81
+ * Beckhoff magnet plate sets — the part carrying the mover's magnets and,
82
+ * on the AT9001 family, the encoder flag ("Geberfahne").
83
+ *
84
+ * The digit after the dash is the pole count in base 16 (`0450` → 4,
85
+ * `0550` → 5, `0775` → 7, `0AA0` → 10), verified by counting magnets in each
86
+ * converted mesh rather than read off the part number. `ATH9001` is the
87
+ * hygienic family, whose rows are encapsulated in sealed stainless.
88
+ *
89
+ * Every set is symmetric about its own travel axis, so its GLB origin already
90
+ * sits on the magnet-plate centre that the mover frame is defined around.
91
+ */
92
+ type MagnetPlateType3D = 'AT9001_0450' | 'AT9001_0550' | 'AT9001_0775' | 'AT9001_0AA0' | 'ATH9001_0550' | 'ATH9001_0550_0001' | 'ATH9001_0AA0';
69
93
  /** Predefined mover-tool / carrier-plate types. */
70
94
  type MoverToolType3D = 'AT8200_1000_0100' | 'AT8200_2000_0100' | 'Custom';
71
95
  /**
@@ -73,28 +97,23 @@ type MoverToolType3D = 'AT8200_1000_0100' | 'AT8200_2000_0100' | 'Custom';
73
97
  * when `RailSystem === 'Beckhoff'` to visualise the outer guide
74
98
  * profile that the mover wheels engage.
75
99
  *
76
- * Curve sign drives the selection (see `MODULE_GUIDING_RAIL_MAP`):
100
+ * Curve sign drives the selection, and on curves so does the mover — see
101
+ * `resolveGuidingRailType`:
77
102
  * - Straights: AT9000_0249 / AT9000_0250 / AT9000_0500
78
103
  * - +22.5° curve: AT9020_1250
79
104
  * - −22.5° curve: AT9025_1466
80
105
  * - 45° curve: AT9040_0750
81
106
  * - 180° clothoid (entire AT2050 500 mm path): AT9050_0500
82
107
  *
108
+ * Beckhoff builds the curve rails per mover: the unsuffixed types above are
109
+ * the `-0055` order variants, made for the AT9014-0055, and the `_0170` ones
110
+ * are the `-0170` variants for the wider AT9014-0070. The straights carry no
111
+ * such variant — one AT9000 profile serves every mover.
112
+ *
83
113
  * Hygienic ATH modules ship with their guide profile baked into the module
84
114
  * GLB and therefore do NOT receive a separate guiding-rail mesh.
85
115
  */
86
- type RailType3D = 'AT9000_0249' | 'AT9000_0250' | 'AT9000_0500' | 'AT9020_1250' | 'AT9025_1466' | 'AT9040_0750' | 'AT9050_0500';
87
-
88
- /**
89
- * Scalar tuples shared by every other type module.
90
- *
91
- * The leaf of the type graph: `Vec3` is a position or an Euler triple in
92
- * millimetres / degrees, `Vec2` a viewport or margin pair in pixels. Kept
93
- * apart from the layout types so `display`, `annotations` and `config` can
94
- * all reach them without importing each other.
95
- */
96
- type Vec3 = [number, number, number];
97
- type Vec2 = [number, number];
116
+ type RailType3D = 'AT9000_0249' | 'AT9000_0250' | 'AT9000_0500' | 'AT9020_1250' | 'AT9020_1250_0170' | 'AT9025_1466' | 'AT9025_1466_0170' | 'AT9040_0750' | 'AT9050_0500' | 'AT9050_0500_0170';
98
117
 
99
118
  /**
100
119
  * Display options — how a layout is drawn, as opposed to what it is.
@@ -128,10 +147,10 @@ interface DisplayOptions {
128
147
  * `'viewer'` (no transform — current behaviour).
129
148
  */
130
149
  coordinateSystem?: CoordinateSystem;
131
- displayStations?: boolean;
150
+ showStations?: boolean;
132
151
  /** Render configured areas. Default: true (when `areas[]` is set). */
133
- displayAreas?: boolean;
134
- displayDimensions?: boolean;
152
+ showAreas?: boolean;
153
+ showDimensions?: boolean;
135
154
  showDriveErrors?: boolean;
136
155
  showDriveWarnings?: boolean;
137
156
  /**
@@ -181,10 +200,6 @@ interface DisplayOptions {
181
200
  * boundary. Off by default.
182
201
  */
183
202
  showModuleCornerMarkers?: boolean;
184
- /** Diameter (mm) of the corner-marker spheres. Default: 10. */
185
- moduleCornerMarkerSizeMm?: number;
186
- /** Color of the corner markers. Default: '#FF2030' (red). */
187
- moduleCornerMarkerColor?: string;
188
203
  /**
189
204
  * Render a red dot + vertical centre-line at each mover's anchor point —
190
205
  * i.e. the track path point the mover is commanded to (its expected
@@ -196,10 +211,6 @@ interface DisplayOptions {
196
211
  * default.
197
212
  */
198
213
  showMoverAnchorMarker?: boolean;
199
- /** Diameter (mm) of the anchor-marker dot. Default: 10. */
200
- moverAnchorMarkerSizeMm?: number;
201
- /** Color of the anchor marker. Default: '#FF2030' (red). */
202
- moverAnchorMarkerColor?: string;
203
214
  /**
204
215
  * Print each module's type designation onto its type-plate panel.
205
216
  *
@@ -212,97 +223,114 @@ interface DisplayOptions {
212
223
  * Default: `true`.
213
224
  */
214
225
  moduleTypePlates?: boolean;
226
+ /** Size and colour of the module corner markers. */
227
+ moduleCornerMarkerOptions?: CalibrationMarkerOptions;
228
+ /** Size and colour of the mover anchor marker. */
229
+ moverAnchorMarkerOptions?: CalibrationMarkerOptions;
230
+ /** Lighting, environment map and tone mapping. */
231
+ lighting?: LightingOptions;
232
+ /** Screen-space effects (SSAO, bloom). */
233
+ postProcessing?: PostProcessingOptions;
234
+ }
235
+ /**
236
+ * Size and colour of a calibration marker.
237
+ *
238
+ * The corner markers and the anchor marker each used to carry a loose
239
+ * `...SizeMm` / `...Color` pair on `DisplayOptions`, which is four flat
240
+ * fields describing two objects.
241
+ */
242
+ interface CalibrationMarkerOptions {
243
+ /** Diameter in mm. Default: 10. */
244
+ sizeMm?: number;
245
+ /** CSS colour. Default: `'#FF2030'` (red). */
246
+ color?: string;
247
+ }
248
+ /**
249
+ * Lighting and the renderer's colour pipeline.
250
+ *
251
+ * Grouped rather than flat because these travel together: the whole block is
252
+ * handed to `<SceneLighting>` and the renderer, and none of it describes a
253
+ * layout. `DisplayOptions` already models per-overlay styling this way
254
+ * (`stationOptions`, `areaOptions`, `dimensionOptions`), so eleven loose
255
+ * scalars sitting beside them was the odd shape out.
256
+ */
257
+ interface LightingOptions {
215
258
  /**
216
259
  * Render shadows: enables the WebGL shadow map, makes one directional
217
- * light cast shadows, and renders a transparent shadow-catcher plane
218
- * so shadows from modules / movers / tools become visible. Default:
219
- * false (no shadow-map cost when disabled). All loaded GLB meshes
220
- * have their `castShadow` + `receiveShadow` flags set unconditionally
221
- * — turning shadows on at the canvas level is the only thing that
222
- * actually triggers the per-frame shadow pass.
260
+ * light cast shadows, and renders a transparent shadow-catcher plane so
261
+ * shadows from modules / movers / tools become visible. Default: false
262
+ * (no shadow-map cost when disabled). All loaded GLB meshes have their
263
+ * `castShadow` + `receiveShadow` flags set unconditionally — turning
264
+ * shadows on at the canvas level is the only thing that actually triggers
265
+ * the per-frame shadow pass.
223
266
  */
224
267
  shadows?: boolean;
225
268
  /**
226
- * Image-based lighting via a procedural indoor environment map
227
- * (three's `RoomEnvironment` processed through `PMREMGenerator` —
228
- * no HTTP fetch, no asset shipped). Drives PBR reflections on
229
- * metallic / roughness materials so aluminium rails and brushed
230
- * mover plates read as actual metal instead of flat shaded surfaces.
269
+ * Image-based lighting via a procedural indoor environment map (three's
270
+ * `RoomEnvironment` processed through `PMREMGenerator` — no HTTP fetch, no
271
+ * asset shipped). Drives PBR reflections on metallic / roughness materials
272
+ * so aluminium rails and brushed mover plates read as actual metal instead
273
+ * of flat shaded surfaces.
231
274
  *
232
- * Cost: one-time PMREM build on mount (≈ 1.5 MB GPU), zero per-frame
275
+ * Cost: one-time PMREM build on mount (~1.5 MB GPU), zero per-frame
233
276
  * overhead beyond the regular PBR shader's environment lookup.
234
277
  *
235
- * Default: `true` — looks noticeably better at no measurable cost.
236
- * Set `false` to fall back to direct-lighting only.
278
+ * Default: `true` — looks noticeably better at no measurable cost. Set
279
+ * `false` to fall back to direct-lighting only.
237
280
  */
238
- environmentLighting?: boolean;
281
+ environment?: boolean;
239
282
  /**
240
- * Strength of the environment-map contribution. Lower values keep
241
- * the look closer to the previous direct-lighting-only baseline;
242
- * higher values emphasise reflections.
243
- *
244
- * Default: `0.4` — reflections visible without overwhelming the
245
- * directional + ambient lights. Range typically `0.0` … `1.5`.
283
+ * Strength of the environment-map contribution. Lower values keep the look
284
+ * closer to the direct-lighting-only baseline; higher values emphasise
285
+ * reflections. Default: `0.4`. Range typically `0.0` ... `1.5`.
246
286
  */
247
287
  environmentIntensity?: number;
248
288
  /**
249
- * Renderer tone-mapping operator. ACES filmic compresses bright
250
- * highlights into a perceptually pleasant rolloff and is the closest
251
- * match to film / professional CAD viewers; `'linear'` reproduces the
252
- * pre-tone-mapping behaviour for callers who relied on it.
253
- *
254
- * Default: `'aces'`.
289
+ * Renderer tone-mapping operator. ACES filmic compresses bright highlights
290
+ * into a perceptually pleasant rolloff and is the closest match to film /
291
+ * professional CAD viewers; `'linear'` reproduces the pre-tone-mapping
292
+ * behaviour for callers who relied on it. Default: `'aces'`.
255
293
  */
256
294
  toneMapping?: 'aces' | 'linear' | 'reinhard' | 'cineon' | 'agx' | 'none';
257
295
  /**
258
296
  * Tone-mapping exposure multiplier (analogous to a camera's exposure
259
- * stop). 1.0 = neutral. Bumped above 1 brightens the result, below 1
260
- * darkens it.
261
- *
297
+ * stop). 1.0 = neutral. Above 1 brightens, below 1 darkens.
262
298
  * Default: `1.0`.
263
299
  */
264
300
  toneMappingExposure?: number;
301
+ }
302
+ /**
303
+ * Screen-space post-processing. Each effect is an extra half-res pass per
304
+ * frame, so both are opt-in.
305
+ *
306
+ * The whole block is forwarded to `<ScenePostProcessing>` unchanged — as
307
+ * seven flat fields it had to be re-threaded one by one, and every default
308
+ * restated at the far end.
309
+ */
310
+ interface PostProcessingOptions {
265
311
  /**
266
- * Screen-space ambient occlusion (GTAO). Adds contact shadows in
267
- * crevices and between adjacent surfaces for significantly more visual
268
- * depth. Cost: one half-res pass per frame.
269
- *
312
+ * Screen-space ambient occlusion (GTAO). Adds contact shadows in crevices
313
+ * and between adjacent surfaces for significantly more visual depth.
270
314
  * Default: `false`.
271
315
  */
272
316
  ssao?: boolean;
273
- /**
274
- * SSAO blend intensity. Higher values darken crevices more.
275
- * Range: 0.0–4.0. Default: `1.0`.
276
- */
317
+ /** SSAO blend intensity; higher darkens crevices more. 0.0-4.0. Default `1.0`. */
277
318
  ssaoIntensity?: number;
278
- /**
279
- * SSAO sampling radius in world units (mm). Larger values spread the
280
- * occlusion over a wider area. Default: `20`.
281
- */
319
+ /** SSAO sampling radius in world units (mm). Default `20`. */
282
320
  ssaoRadius?: number;
283
321
  /**
284
322
  * Bloom (glow) for bright metallic surfaces. Adds a soft halo around
285
- * polished rails and highlights, giving metals a more realistic
286
- * reflective appearance. Cost: one half-res pass per frame.
287
- *
288
- * Default: `false`.
323
+ * polished rails and highlights. Default: `false`.
289
324
  */
290
325
  bloom?: boolean;
291
- /**
292
- * Bloom intensity (strength). Higher values produce brighter glow.
293
- * Range: 0.0–3.0. Default: `0.4`.
294
- */
326
+ /** Bloom strength; higher produces brighter glow. 0.0-3.0. Default `0.4`. */
295
327
  bloomIntensity?: number;
296
328
  /**
297
- * Bloom luminance threshold. Only pixels brighter than this value
298
- * contribute to the bloom. Lower values bloom more of the scene.
299
- * Range: 0.0–1.0. Default: `0.85`.
329
+ * Bloom luminance threshold - only pixels brighter than this contribute.
330
+ * Lower values bloom more of the scene. 0.0-1.0. Default `0.85`.
300
331
  */
301
332
  bloomThreshold?: number;
302
- /**
303
- * Bloom blur radius. Controls how far the glow spreads.
304
- * Range: 0.0–1.0. Default: `0.4`.
305
- */
333
+ /** Bloom blur radius; how far the glow spreads. 0.0-1.0. Default `0.4`. */
306
334
  bloomRadius?: number;
307
335
  }
308
336
  /**
@@ -330,8 +358,13 @@ interface MoverIdLabelOptions {
330
358
  */
331
359
  offsetMm?: Vec3;
332
360
  /**
333
- * URL of a TTF / OTF / WOFF font, passed straight to troika-three-text.
334
- * Default: `DEFAULT_LABEL_FONT_URL` (Roboto Condensed via jsDelivr).
361
+ * URL of a TTF / OTF / WOFF font for the label.
362
+ *
363
+ * Default: the viewer's built-in Roboto Condensed, compiled into the
364
+ * bundle and served from a blob URL — no request, and nothing that can
365
+ * fail. A custom URL is fetched once and shared by every label; until
366
+ * it answers, and permanently if it never does, labels render in the
367
+ * built-in font and the failure is reported once via `console.warn`.
335
368
  */
336
369
  fontUrl?: string;
337
370
  /** Outline thickness in mm. 0 disables the outline. Default: 0. */
@@ -457,7 +490,6 @@ interface DimensionOptions {
457
490
  */
458
491
  lateralDisplacementMm?: number;
459
492
  textOptions?: TextOptions;
460
- textPattern?: number;
461
493
  /**
462
494
  * Show numeric mm-value labels at every dimension tick (module boundaries
463
495
  * and — when enabled — intermediate steps). Labels are billboards that
@@ -675,6 +707,27 @@ interface ProcessingUnitConfig {
675
707
  */
676
708
  objectId: number;
677
709
  moverType: MoverType3D;
710
+ /**
711
+ * Magnet plate set attached to the carriage.
712
+ *
713
+ * Only meaningful for a mover whose GLB ships bare — the whole Hepco
714
+ * `GFX2-1TC` family. Every other mover models its plate already (see
715
+ * `MOVER_INTEGRATED_PLATE`) and ignores this, so setting it there cannot
716
+ * double-plate the mover. Defaults to `AT9001_0550`, the 5-pole set.
717
+ *
718
+ * This is how pole count is chosen. A 7-pole M34, which used to be its own
719
+ * `Hepco_GFX2_1TC_M34_7P` mover type, is the same carriage with a different
720
+ * plate on it:
721
+ *
722
+ * ```ts
723
+ * { moverType: 'Hepco_GFX2_1TC_M34', magnetPlateType: 'AT9001_0775' }
724
+ * ```
725
+ *
726
+ * Deliberately per processing unit rather than per mover: `moverType` is
727
+ * too, and a plate that varied between movers would force every one of them
728
+ * to claim its own scene node, disabling the instanced render path.
729
+ */
730
+ magnetPlateType?: MagnetPlateType3D;
678
731
  customMoverLayout?: CustomMoverLayout;
679
732
  /** Default: 'Beckhoff'. */
680
733
  railSystem?: RailSystem;
@@ -845,6 +898,20 @@ interface ModuleStatusEntry {
845
898
  warning?: boolean;
846
899
  error?: boolean;
847
900
  }
901
+ /**
902
+ * Runtime state for a single mover tool — pushed through
903
+ * `XtsViewer3DRef.setMoverToolStates(update)`, keyed by `MoverConfig.id`
904
+ * and then by `MoverToolConfig.id`. A stored entry overrides the
905
+ * config-time `MoverToolConfig.visible`; `null` drops back to it.
906
+ *
907
+ * Visibility only: it is the state a host changes while a scene runs — a
908
+ * simulated line loading a product onto a mover at one station and taking
909
+ * it off at another. Placement, rotation and opacity describe what a tool
910
+ * IS and stay in the configuration.
911
+ */
912
+ interface MoverToolStateEntry {
913
+ visible?: boolean;
914
+ }
848
915
  interface MoverToolConfig {
849
916
  toolType: MoverToolType3D;
850
917
  /** Required when toolType === 'Custom'. */
@@ -902,1212 +969,1506 @@ interface CustomAssetConfig {
902
969
  }
903
970
 
904
971
  /**
905
- * Interaction surface — selection, highlighting and the camera.
972
+ * The transform gizmo — the translate / rotate / scale handles the viewer
973
+ * draws on whatever is selected.
906
974
  *
907
- * The types a host exchanges with the viewer at runtime rather than at
908
- * configuration time: what is addressed (`ModuleRef`, `MoverRef`), what is
909
- * picked out (`SelectionState`, `ModuleHighlight`, `FeedSegmentHighlight`),
910
- * and where the camera looks (`CameraState`, `FocusTarget`).
911
- */
912
-
913
- interface ModuleRef {
914
- processingUnitObjectId: number;
915
- partObjectId: number;
916
- moduleIndex: number;
917
- }
918
- interface MoverRef {
919
- processingUnitObjectId: number;
920
- moverIndex: number;
921
- }
922
- interface SelectionState {
923
- modules: ModuleRef[];
924
- movers: MoverRef[];
925
- }
926
- type SelectionMode = 'Off' | 'Single' | 'Multi';
927
- /**
928
- * Declarative module highlight. All modules whose `moduleType` appears in
929
- * `moduleTypes` are tinted with the given `color` — same emissive effect as
930
- * selection, but independent of the selection state. Multiple highlights can
931
- * overlap; the first matching entry wins.
975
+ * Issue #107 asks for "OrbitControls … für Rotation, Translation und
976
+ * Scaling" on a selected object or a whole track. Those are two different
977
+ * things: `OrbitControls` is the camera, and what moves an *object* is a
978
+ * transform gizmo, so that is what this is called. The camera controls are
979
+ * unchanged and keep living behind `lock` / `keyboardNavigation`.
932
980
  *
933
- * Toggle on/off by setting `moduleHighlights` on `<XtsViewer3D>` to an
934
- * array (enabled) or `undefined` / `[]` (disabled).
935
- */
936
- interface ModuleHighlight {
937
- /** Module types to highlight. */
938
- moduleTypes: ModuleType3D[];
939
- /** Highlight color (CSS color string, e.g. `'#FF6600'`). */
940
- color: string;
941
- }
942
- /**
943
- * Addresses one feed segment (Einspeisestrang). Either form works — pick
944
- * whichever the host has at hand:
981
+ * What can be posed is what the configuration gives a free pose to:
945
982
  *
946
- * - `{ infeedModuleGlobalNumber }` — the `ModuleEntry.globalNumber` of the
947
- * infeed module that leads the segment. Stable across layout edits as long
948
- * as the global numbering is stable.
949
- * - `{ partObjectId, segmentIndex }` — ordinal address: `segmentIndex` counts
950
- * 0, 1, 2 … in chain order, starting at the part's first infeed module.
983
+ * - a **track** (`ProcessingUnitConfig.trackTransform`) — position,
984
+ * rotation and uniform scale;
985
+ * - a **static custom asset** (`CustomAssetConfig.binding` with
986
+ * `type: 'static'`) — the same three.
987
+ *
988
+ * A module or a mover is NOT a target and never grows handles: modules bolt
989
+ * together end-to-end along the chain and movers ride it, so neither has a
990
+ * pose of its own, and posing "the track it belongs to" instead would move
991
+ * something other than what the user picked. Select the track — that is
992
+ * what `selectionScope: 'track'` is for. A mover-bound custom asset rides
993
+ * its mover for the same reason and is not a target either.
951
994
  */
952
- type FeedSegmentSelector = {
953
- infeedModuleGlobalNumber: number;
995
+
996
+ /** Which handle set the gizmo shows. */
997
+ type TransformGizmoMode = 'translate' | 'rotate' | 'scale';
998
+ /** What the handles move. */
999
+ type TransformGizmoTarget = {
1000
+ kind: 'track';
1001
+ processingUnitObjectId: number;
954
1002
  } | {
955
- partObjectId: number;
956
- segmentIndex: number;
1003
+ kind: 'customAsset';
1004
+ assetId: string;
957
1005
  };
958
1006
  /**
959
- * Declarative feed-segment highlight. Every module belonging to one of the
960
- * selected segments is tinted with `color` — same emissive effect as
961
- * selection, but independent of the selection state.
962
- *
963
- * Multiple entries are allowed (that is how several segments get several
964
- * colors at once); on overlap the first matching entry wins, mirroring
965
- * `ModuleHighlight`.
966
- *
967
- * Toggle on/off by setting `feedSegmentHighlights` on `<XtsViewer3D>` to an
968
- * array (enabled) or `undefined` / `[]` (disabled).
1007
+ * A free pose, in the frame the configuration states it in — the root
1008
+ * `XtsConfig.orientation` frame for both target kinds.
969
1009
  */
970
- interface FeedSegmentHighlight {
971
- /** Segments to tint. */
972
- segments: FeedSegmentSelector[];
973
- /** Highlight color (CSS color string, e.g. `'#00AAFF'`). */
974
- color: string;
1010
+ interface TransformGizmoPose {
1011
+ positionMm: Vec3;
1012
+ /** Intrinsic XYZ Euler angles in degrees. */
1013
+ rotationDegEuler: Vec3;
1014
+ /** Uniform scale factor. */
1015
+ scaleUniform: number;
975
1016
  }
976
- /**
977
- * One feed segment (Einspeisestrang) as detected in the configuration: the
978
- * contiguous run of modules starting at an infeed module (Einspeisemodul,
979
- * `ModuleCatalogEntry.isInfeed`) and ending right before the next infeed
980
- * module of the same part.
981
- *
982
- * On a closed chain (oval, U-turn pair — see `isChainClosed`) the modules
983
- * ahead of the part's first infeed module belong to the LAST segment, which
984
- * then carries `wrapsSeam: true`. On an open chain those leading modules
985
- * belong to no segment at all and cannot be tinted.
986
- *
987
- * Returned by `XtsViewer3DRef.getFeedSegments()`.
988
- */
989
- interface FeedSegment {
990
- processingUnitObjectId: number;
991
- partObjectId: number;
992
- /** 0-based, in chain order, counted from the part's first infeed module. */
993
- segmentIndex: number;
1017
+ /** One drag, reported to the host. */
1018
+ interface TransformGizmoChange {
1019
+ target: TransformGizmoTarget;
1020
+ /** Which kind of handle produced it — all three are on screen at once. */
1021
+ mode: TransformGizmoMode;
1022
+ /** The pose after the drag, in the host's own coordinate convention. */
1023
+ pose: TransformGizmoPose;
994
1024
  /**
995
- * `ModuleEntry.globalNumber` of the leading infeed module. `undefined` when
996
- * the config does not set it — such a segment can only be addressed via
997
- * `{ partObjectId, segmentIndex }`.
1025
+ * `pose` in the shape `trackTransformOverrides` / `trackTransform` take.
1026
+ * Empty for a `'customAsset'` target.
998
1027
  */
999
- infeedModuleGlobalNumber?: number;
1000
- /** Module type of the leading infeed module. */
1001
- infeedModuleType: ModuleType3D;
1002
- /** Chain indices of every module in this segment, in chain order. */
1003
- moduleIndices: number[];
1004
- /** Σ of the segment's module lengths, in mm. */
1005
- lengthMm: number;
1006
- /** True when the segment wraps the closed-loop seam. */
1007
- wrapsSeam: boolean;
1008
- }
1009
- interface CameraState {
1010
- positionMm: Vec3;
1011
- targetMm: Vec3;
1012
- /** Always 'Z' in the current spec. */
1013
- upAxis: 'Z' | 'Y';
1014
- zoom?: number;
1028
+ trackTransform?: TrackTransform;
1015
1029
  }
1016
1030
  /**
1017
- * Live camera projection.
1018
- * - `'perspective'` (default) — the regular 3D view.
1019
- * - `'orthographic'` — paired with `topDown`, gives the flat 2D plan view
1020
- * that matches `exportScreenshot({ mode: 'top-down' })`.
1021
- */
1022
- type CameraProjection = 'perspective' | 'orthographic';
1023
- /**
1024
- * Target for the imperative `viewerRef.current.focusOn(target, opts)` camera
1025
- * animation. The viewer computes the target's world-space bounding box and
1026
- * flies the camera so the whole object fits the frame:
1027
- * - In `'perspective'` projection the current view angle is preserved
1028
- * (the camera only dollies / re-centres).
1029
- * - In `'orthographic'` top-down the frustum is re-framed and the camera
1030
- * pans straight over the object.
1031
- */
1032
- type FocusTarget = {
1033
- kind: 'scene';
1034
- } | {
1035
- kind: 'station';
1036
- stationId: number;
1037
- } | {
1038
- kind: 'area';
1039
- areaId: number;
1040
- } | {
1041
- kind: 'mover';
1042
- ref: MoverRef;
1043
- } | {
1044
- kind: 'module';
1045
- ref: ModuleRef;
1046
- };
1047
- interface FocusOptions {
1048
- /** Animation duration in ms. Default 700. `0` jumps immediately. */
1049
- durationMs?: number;
1031
+ * Transform-gizmo configuration. Off unless `enabled` is set, so a viewer
1032
+ * that only displays a layout never grows draggable handles.
1033
+ *
1034
+ * The gizmo is drawn in the same palette as the corner navigation gizmo —
1035
+ * X red, Y green, Z blue — and sits at the target's origin.
1036
+ *
1037
+ * Applying a drag: the viewer shows it immediately, and reports it through
1038
+ * `onTransformChange`. A host that persists the change (into its config, or
1039
+ * into `trackTransformOverrides`) takes over from the viewer's own copy the
1040
+ * moment its value arrives; a host that ignores the callback keeps the
1041
+ * viewer's. Either way the object never jumps.
1042
+ *
1043
+ * @example
1044
+ * ```tsx
1045
+ * <XtsViewer3D
1046
+ * selectionMode="Single"
1047
+ * selectionScope="track"
1048
+ * transformGizmo={{
1049
+ * enabled: true,
1050
+ * translateSnapMm: 10,
1051
+ * onTransformChange: ({ target, trackTransform }) => {
1052
+ * if (target.kind === 'track' && trackTransform) {
1053
+ * setOverrides((o) => ({
1054
+ * ...o,
1055
+ * [target.processingUnitObjectId]: trackTransform,
1056
+ * }));
1057
+ * }
1058
+ * },
1059
+ * }}
1060
+ * />
1061
+ * ```
1062
+ */
1063
+ interface TransformGizmoOptions {
1064
+ /** Draw the handles at all. Default `false`. */
1065
+ enabled?: boolean;
1050
1066
  /**
1051
- * Padding around the target's bounding box (1 = tight fit). Default 1.2 in
1052
- * perspective, 1.1 in orthographic top-down.
1067
+ * Which handle sets to draw. Default: all three at once, which is the
1068
+ * point of the thing — move, turn and scale without first telling a
1069
+ * toolbar which of them you meant.
1070
+ *
1071
+ * They are told apart by shape and position rather than by a mode: the
1072
+ * translation arrow runs out along the positive half of each axis, the
1073
+ * scale cube along the negative half (drag away from the object to grow
1074
+ * it), and the rotation ring circles both. Narrow the list to leave a
1075
+ * kind of edit out entirely — `['translate', 'rotate']` for a layout
1076
+ * whose tracks must keep their scale.
1053
1077
  */
1054
- paddingFactor?: number;
1055
- /** Easing curve. Default `'easeInOutCubic'`. */
1056
- easing?: 'linear' | 'easeInOutCubic';
1078
+ handles?: TransformGizmoMode[];
1079
+ /**
1080
+ * What the handles move. Default `'selection'` — follow the selection: a
1081
+ * selected custom asset, or a selected whole track (which is what a click
1082
+ * produces under `selectionScope: 'track'`). A selected module or mover
1083
+ * shows no handles at all.
1084
+ *
1085
+ * Pass a `TransformGizmoTarget` to drive the gizmo from outside instead;
1086
+ * the selection is then ignored — that is the path for a host that picks
1087
+ * the track from its own list rather than from the canvas. `'none'` keeps
1088
+ * the feature configured but shows nothing.
1089
+ */
1090
+ target?: 'selection' | 'none' | TransformGizmoTarget;
1091
+ /** On-screen length of one axis, in CSS px. Default `90`. */
1092
+ sizePx?: number;
1093
+ /**
1094
+ * Axis colours in x / y / z order. Defaults to the navigation gizmo's
1095
+ * palette, which is what makes the two read as one family.
1096
+ */
1097
+ axisColors?: readonly [string, string, string];
1098
+ /** Round translation to this step, in mm. Default: no snapping. */
1099
+ translateSnapMm?: number;
1100
+ /** Round rotation to this step, in degrees. Default: no snapping. */
1101
+ rotateSnapDeg?: number;
1102
+ /** Round the scale factor to this step. Default: no snapping. */
1103
+ scaleSnap?: number;
1104
+ /** Smallest scale a drag can reach. Default `0.05`. */
1105
+ minScale?: number;
1106
+ /** A drag started. */
1107
+ onTransformStart?: (target: TransformGizmoTarget) => void;
1108
+ /** Fires continuously while dragging. */
1109
+ onTransformChange?: (change: TransformGizmoChange) => void;
1110
+ /** Fires once, on pointer release. */
1111
+ onTransformEnd?: (change: TransformGizmoChange) => void;
1057
1112
  }
1058
1113
 
1059
1114
  /**
1060
- * Viewer errors.
1115
+ * Module catalog (3D-canonical).
1061
1116
  *
1062
- * `XtsViewerErrorException` is the one runtime value in the public type
1063
- * surface — everything else the viewer exports as `types` is erased at
1064
- * compile time. It is thrown by the normalization pass for configurations
1065
- * that cannot be rendered at all; recoverable problems are reported through
1066
- * the `onError` callback as plain `XtsViewerError` records instead.
1067
- */
1068
- type XtsViewerErrorCode = 'asset-load-failed' | 'unknown-module-type' | 'unknown-mover-type' | 'unknown-tool-type' | 'invalid-config' | 'unmatched-clothoid-half' | 'webgl-context-lost';
1069
- interface XtsViewerError {
1070
- code: XtsViewerErrorCode;
1071
- message: string;
1072
- details?: unknown;
1073
- }
1074
- declare class XtsViewerErrorException extends Error {
1075
- readonly code: XtsViewerErrorCode;
1076
- readonly details?: unknown;
1077
- constructor(code: XtsViewerErrorCode, message: string, details?: unknown);
1078
- }
1079
-
1080
- /**
1081
- * Asset manifest — per-type overrides for the GLB URLs the viewer loads.
1117
+ * Every module declares its path topology (Straight / Curve / Free), its
1118
+ * arc length, and — for curves — the signed end-of-module yaw delta in
1119
+ * degrees. Centerline radius for analytic curves is derived from
1120
+ * `R = L / |θ_rad|`.
1082
1121
  *
1083
- * A host registers custom or relocated assets here instead of replacing the
1084
- * whole `assetsBaseUrl`. Entries fall back to the default CDN mapping for any
1085
- * type they do not name.
1122
+ * Hygienic ATH variants share path math with their AT pendants — only the
1123
+ * GLB mesh differs.
1124
+ *
1125
+ * This file is hand-curated. The geometry module imports from here directly
1126
+ * (no JSON fetch) so the library has zero runtime data deps.
1086
1127
  */
1087
1128
 
1088
- interface AssetManifest {
1089
- /** Override per moduleType. Falls back to default mapping otherwise. */
1090
- modules?: Partial<Record<ModuleType3D, ModuleAssetEntry>>;
1091
- movers?: Partial<Record<MoverType3D, MoverAssetEntry>>;
1092
- tools?: Partial<Record<MoverToolType3D, MoverToolAssetEntry>>;
1093
- guidingRails?: Partial<Record<RailType3D, RailAssetEntry>>;
1129
+ type PathType = 'Straight' | 'Curve' | 'Free';
1130
+ interface ModuleCatalogEntry {
1131
+ moduleType: ModuleType3D;
1132
+ /** Arc length along the centerline, in mm. */
1133
+ moduleLengthMm: number;
1134
+ pathType: PathType;
1135
+ /**
1136
+ * Encoder-side end-of-module yaw, in degrees, in the module-local 3D frame.
1137
+ * Sign convention: positive endAngle ⇒ Y-flip math curves toward
1138
+ * −Y (= right of path). 0 for Straight. 180 for AT2050_*_180 free path.
1139
+ */
1140
+ endAngleDeg: number;
1141
+ /**
1142
+ * Centerline radius for analytic curves, in mm. `null` for Straight + Free.
1143
+ * Computed as R = L / |θ_rad|; equals 636.6198 for ±22.5° and 318.3099 for
1144
+ * ±45° curves at L=250.
1145
+ */
1146
+ centerlineRadiusMm: number | null;
1147
+ /**
1148
+ * Free-path point cloud reference. Only set for `pathType==='Free'`.
1149
+ * The string identifies the cloud by name; the actual data lives in
1150
+ * geometry/PointClouds.ts.
1151
+ */
1152
+ freePathId?: string;
1153
+ /** True if STP is not delivered yet. */
1154
+ prepared?: boolean;
1155
+ /** True for Einspeisemodule (power feed-in variants). */
1156
+ isInfeed?: boolean;
1094
1157
  }
1095
- interface RailAssetEntry {
1096
- glbUrl: string;
1097
- sidecarUrl?: string;
1158
+ declare const MODULE_CATALOG: Readonly<Record<ModuleType3D, ModuleCatalogEntry>>;
1159
+ /**
1160
+ * Beckhoff XTS infeed module types (Einspeisemodule). Convenience constant
1161
+ * for use with `moduleHighlights` on `<XtsViewer3D>`.
1162
+ *
1163
+ * Derived from the catalog (`isInfeed` flag) so it never drifts out of sync —
1164
+ * mark a new module with `isInfeed: true` in `MODULE_CATALOG` and it is
1165
+ * automatically included here.
1166
+ */
1167
+ declare const INFEED_MODULE_TYPES: readonly ModuleType3D[];
1168
+ /** Lookup with type-narrowing. Returns undefined for unknown types. */
1169
+ declare function getModuleEntry(moduleType: ModuleType3D): ModuleCatalogEntry | undefined;
1170
+ /** Type-guard for safe lookups from untyped (e.g. JSON) sources. */
1171
+ declare function isKnownModuleType(value: string): value is ModuleType3D;
1172
+
1173
+ /**
1174
+ * ChainBuilder — builds the per-module `startWorldMatrix` for a Part.
1175
+ *
1176
+ * Chain composition (simplified — modules butt up edge-to-edge in this build):
1177
+ * chain_world = Identity
1178
+ * for each module_i:
1179
+ * module_i.startWorldMatrix = chain_world
1180
+ * module_i.startPositionInPartMm = cumulative
1181
+ * module_i.trackLengthMm = module_i.moduleLengthMm
1182
+ * cumulative += trackLengthMm
1183
+ * end_local = T( ΔX, -ΔY_canvas, 0 ) ⊗ R_z( -θ )
1184
+ * chain_world = chain_world ⊗ end_local
1185
+ *
1186
+ * `chain_world` is a planar pose: 2D translation (X,Y) + Z-yaw. We carry it as
1187
+ * a tiny struct rather than a full 4×4 to keep the pure-math layer free of
1188
+ * Three.js. A Three.js helper in PathBuilder.ts converts to Matrix4 on demand.
1189
+ */
1190
+
1191
+ /** Planar pose (X,Y,yaw) in a 3D-RH-Z-up frame. Z is always 0 here. */
1192
+ interface PlanarPose {
1193
+ positionMm: Vec3;
1194
+ yawDeg: number;
1098
1195
  }
1099
- interface ModuleAssetEntry {
1100
- /** Per-RailSystem GLB URL (relative to assetsBaseUrl). */
1101
- glbByRailSystem: {
1102
- Beckhoff: string | null;
1103
- HepcoGfx?: string | null;
1196
+ declare const IDENTITY_POSE: PlanarPose;
1197
+ interface BuiltModule {
1198
+ moduleType: ModuleType3D;
1199
+ catalog: ModuleCatalogEntry;
1200
+ /** Pose of the module-local origin (= path entry). */
1201
+ startWorldPose: PlanarPose;
1202
+ /** Distance along the part where this module starts (mm). */
1203
+ startPositionInPartMm: number;
1204
+ /** moduleLength (mm). Used for findModuleContaining. */
1205
+ trackLengthMm: number;
1206
+ /** Forwarded from `ModuleEntry.globalNumber` — index into the runtime
1207
+ * `ModuleStatusStore` so renderers can subscribe per module. */
1208
+ globalNumber?: number;
1209
+ /** Forwarded from `ModuleEntry.status` so the renderer can light up
1210
+ * the per-module drive-status overlay without an extra lookup. */
1211
+ status?: {
1212
+ warning?: boolean;
1213
+ error?: boolean;
1104
1214
  };
1105
- sidecarUrl?: string;
1106
- }
1107
- interface MoverAssetEntry {
1108
- glbUrl: string;
1109
- sidecarUrl?: string;
1110
1215
  }
1111
- interface MoverToolAssetEntry {
1112
- glbUrl: string;
1113
- sidecarUrl?: string;
1216
+ interface BuiltChain {
1217
+ modules: BuiltModule[];
1218
+ /** Σ moduleLength_i. */
1219
+ trackLengthMm: number;
1220
+ /** End-of-chain pose; useful for closure-checks. */
1221
+ endPose: PlanarPose;
1114
1222
  }
1115
-
1116
1223
  /**
1117
- * Public types for the measurement + annotation feature.
1224
+ * Build a chain from a part's module list.
1118
1225
  *
1119
- * Everything here is expressed in the viewer's WORLD frame — millimetres,
1120
- * Z-up, identical to `XtsViewer3DRef.getMoverWorldTransform()` and
1121
- * `CameraState.positionMm`. That is what makes one implementation serve both
1122
- * the perspective 3D view and the orthographic 2D plan view
1123
- * (`projection='orthographic'`): only the camera differs, the geometry does
1124
- * not.
1226
+ * Throws XtsViewerErrorException with code `unknown-module-type` on lookup
1227
+ * miss.
1228
+ */
1229
+ declare function buildChain(modules: ReadonlyArray<ModuleEntry>): BuiltChain;
1230
+ /**
1231
+ * Locate the module containing `partPositionMm` along the chain.
1232
+ * First module with partPosition < startInPart + trackLength; at boundary
1233
+ * `partPosition == trackLength` the LAST module is returned with
1234
+ * `localPos = trackLength`.
1125
1235
  */
1236
+ declare function findModuleAt(chain: BuiltChain, partPositionMm: number): {
1237
+ module: BuiltModule;
1238
+ localPosMm: number;
1239
+ } | null;
1240
+ /**
1241
+ * Mover world pose at `partPositionMm`.
1242
+ *
1243
+ * The module's startWorldPose is composed with the local path sample at
1244
+ * `localPosMm` along that module's path.
1245
+ */
1246
+ declare function moverWorldAt(chain: BuiltChain, partPositionMm: number): PlanarPose | null;
1247
+ /** Convenience: chain from full ModuleEntry[] returning trackLength alone. */
1248
+ declare function trackLengthOf(modules: ReadonlyArray<ModuleEntry>): number;
1126
1249
 
1127
1250
  /**
1128
- * How a measurement point was derived. `'free'` is the raw ray hit (surface
1129
- * or ground plane); everything else is a snap the picker resolved to.
1251
+ * normalizeXtsConfig — pre-render pass.
1252
+ *
1253
+ * Fail-fast validation + AT2050 half-clothoid merging:
1254
+ * AT2050_0500 + AT2050_0501 (gap=0) → AT2050_0500_180
1255
+ * ATH2050_0500 + ATH2050_0501 (gap=0) → ATH2050_0500_180
1256
+ *
1257
+ * Errors thrown via XtsViewerErrorException:
1258
+ * - 'unknown-module-type'
1259
+ * - 'unknown-mover-type'
1260
+ * - 'unmatched-clothoid-half' (0500 without 0501 or vice versa)
1261
+ *
1262
+ * Warnings (collected, non-fatal) returned alongside the normalized config:
1263
+ * - mover.partOid not in part-list
1264
+ * - duplicate mover.id within an XPU
1265
+ * - mover.partPositionMm out of range (clamped)
1130
1266
  */
1131
- type SnapKind = 'free' | 'vertex' | 'edge-midpoint' | 'face-center' | 'grid' | 'mover-center' | 'module-boundary' | 'module-center' | 'station-stop' | 'track-path';
1132
- /** Scene entity a picked point belongs to, when the picker could resolve one. */
1133
- type MeasuredEntityRef = {
1134
- kind: 'module';
1135
- ref: ModuleRef;
1136
- } | {
1137
- kind: 'mover';
1138
- ref: MoverRef;
1139
- } | {
1140
- kind: 'station';
1141
- stationId: number;
1142
- };
1143
- /** A position on a part's track path, in the XPU's user position frame. */
1144
- interface TrackAnchor {
1145
- processingUnitObjectId: number;
1146
- /** `PartConfig.objectId`. */
1147
- partObjectId: number;
1148
- /** Position along the part, user frame (same convention as movers). */
1149
- partPositionMm: number;
1150
- }
1151
- /** One measurement vertex. */
1152
- interface MeasurementPoint {
1153
- /** World position (mm). */
1154
- positionMm: Vec3;
1155
- /** How the point was obtained. Default `'free'` when omitted. */
1156
- snap?: SnapKind;
1157
- /** Entity the point sits on, when known. */
1158
- entity?: MeasuredEntityRef;
1159
- /**
1160
- * Track position, set whenever the point resolved onto a part's path.
1161
- * Required on both endpoints of a `'trackDistance'` measurement.
1162
- */
1163
- track?: TrackAnchor;
1164
- }
1165
- /** Snapping configuration. */
1166
- interface SnapOptions {
1167
- /** Master switch. Default `true`. */
1168
- enabled?: boolean;
1169
- /**
1170
- * Snap kinds to consider, highest priority first. Default:
1171
- * `['vertex', 'edge-midpoint', 'mover-center', 'module-boundary',
1172
- * 'station-stop', 'track-path', 'face-center']`.
1173
- */
1174
- kinds?: SnapKind[];
1175
- /** Screen-space radius (px) a candidate must fall within. Default `14`. */
1176
- radiusPx?: number;
1267
+
1268
+ interface NormalizedXtsConfig extends XtsConfig {
1177
1269
  /**
1178
- * Grid step (mm) for the `'grid'` snap, applied to the free hit point when
1179
- * no other candidate matched and `'grid'` is in `kinds`. Default `0` (off).
1270
+ * Same shape as the input, with all parts' modules pre-merged + validated.
1271
+ * Mover positions are clamped, but the original values are preserved in
1272
+ * `__warnings` for diagnostics.
1180
1273
  */
1181
- gridMm?: number;
1182
- }
1183
- type MeasurementId = string;
1184
- type MeasurementKind = 'distance' | 'angle' | 'path' | 'trackDistance';
1185
- /** Axis constraint for a distance measurement. */
1186
- type MeasurementAxis = 'free' | 'x' | 'y' | 'z';
1187
- interface MeasurementBase {
1188
- id: MeasurementId;
1189
- /** Optional caption rendered above the value. */
1190
- label?: string;
1191
- /** Overrides `MeasurementStyle.color` for this one measurement. */
1192
- color?: string;
1193
- /** Default `true`. */
1194
- visible?: boolean;
1195
- /** Free-form payload the host can round-trip (never read by the viewer). */
1196
- meta?: Record<string, unknown>;
1197
- }
1198
- /** Straight-line distance between two points. */
1199
- interface DistanceMeasurement extends MeasurementBase {
1200
- kind: 'distance';
1201
- points: [MeasurementPoint, MeasurementPoint];
1202
- /** Constrain the reported value to one axis. Default `'free'`. */
1203
- axis?: MeasurementAxis;
1204
- /** Render dashed ΔX / ΔY / ΔZ guides. Default `false`. */
1205
- showComponents?: boolean;
1206
- }
1207
- /** Angle at `points[1]` between the legs to `points[0]` and `points[2]`. */
1208
- interface AngleMeasurement extends MeasurementBase {
1209
- kind: 'angle';
1210
- /** `[legA, vertex, legB]` — the middle point is the vertex. */
1211
- points: [MeasurementPoint, MeasurementPoint, MeasurementPoint];
1274
+ readonly __warnings: NormalizationWarning[];
1212
1275
  }
1213
- /** Polyline through N points; reports per-segment and total length. */
1214
- interface PathMeasurement extends MeasurementBase {
1215
- kind: 'path';
1216
- points: MeasurementPoint[];
1217
- /** Close the polyline back to the first point. Default `false`. */
1218
- closed?: boolean;
1219
- /** Label every segment, not just the total. Default `true`. */
1220
- showSegmentLengths?: boolean;
1276
+ interface NormalizationWarning {
1277
+ code: 'mover-out-of-range' | 'mover-orphan' | 'mover-duplicate-id' | 'mover-missing-id' | 'mover-invalid-id' | 'empty-part' | 'prepared-module' | 'duplicate-xpu-object-id' | 'missing-xpu-object-id' | 'invalid-xpu-object-id' | 'duplicate-part-object-id' | 'missing-part-object-id' | 'invalid-part-object-id' | 'duplicate-part-global-number' | 'missing-part-global-number' | 'invalid-part-global-number' | 'duplicate-module-global-number' | 'missing-module-global-number' | 'invalid-module-global-number';
1278
+ message: string;
1279
+ details?: unknown;
1221
1280
  }
1222
1281
  /**
1223
- * Distance ALONG the XTS track between two points on the same part — the
1224
- * distance a mover actually travels, not the straight line.
1282
+ * Normalizer handle.
1283
+ *
1284
+ * This used to carry a single-slot structural cache keyed on a
1285
+ * `JSON.stringify` of the config with the frame-paced fields stripped out.
1286
+ * It was removed: the only production caller is `useNormalizedConfig`, whose
1287
+ * `useMemo` already collapses repeat calls on an unchanged config, so the
1288
+ * cache could fire only when the config identity changed but its structure
1289
+ * did not — and in exactly that case the "fast path" cost a full stringify
1290
+ * of the config plus a full rebuild of every XPU, which is strictly more
1291
+ * work than the single O(n) pass it was avoiding.
1292
+ *
1293
+ * It also cost correctness twice over. The key stripped `status` by name at
1294
+ * every depth, so it removed `ModuleEntry.status` along with the mover field
1295
+ * it was aimed at, while the clone path rebuilt only movers — freezing
1296
+ * declarative module warnings after the first normalize. And it forced a
1297
+ * second copy of the mover clamp that had already drifted from `clampMover`
1298
+ * (different `details` key, different `original` value), so hosts parsing
1299
+ * warning details saw two shapes for one warning code.
1300
+ *
1301
+ * The class stays because it is part of the published API. It is now a thin
1302
+ * handle over the pure function.
1225
1303
  */
1226
- interface TrackDistanceMeasurement extends MeasurementBase {
1227
- kind: 'trackDistance';
1228
- points: [MeasurementPoint, MeasurementPoint];
1304
+ declare class XtsConfigNormalizer {
1305
+ normalize(config: XtsConfig): NormalizedXtsConfig;
1306
+ /** No-op. Kept so existing callers keep compiling; there is no cache. */
1307
+ clearCache(): void;
1229
1308
  }
1230
- type Measurement = DistanceMeasurement | AngleMeasurement | PathMeasurement | TrackDistanceMeasurement;
1231
- /** Same as `Measurement`, but the store generates the `id` when omitted. */
1232
- type MeasurementInput = (Omit<DistanceMeasurement, 'id'> & {
1233
- id?: MeasurementId;
1234
- }) | (Omit<AngleMeasurement, 'id'> & {
1235
- id?: MeasurementId;
1236
- }) | (Omit<PathMeasurement, 'id'> & {
1237
- id?: MeasurementId;
1238
- }) | (Omit<TrackDistanceMeasurement, 'id'> & {
1239
- id?: MeasurementId;
1240
- });
1241
- /** Computed value of a measurement. */
1242
- interface MeasurementResult {
1243
- id: MeasurementId;
1244
- kind: MeasurementKind;
1245
- /** Millimetres for lengths, degrees for angles. */
1246
- value: number;
1247
- unit: 'mm' | 'deg';
1248
- /** Formatted value as rendered in the scene (e.g. `'1 234.5 mm'`). */
1249
- text: string;
1250
- /** Signed component deltas — `'distance'` only. */
1251
- deltaMm?: Vec3;
1252
- /** Per-segment lengths — `'path'` only. */
1253
- segmentLengthsMm?: number[];
1309
+ declare function normalizeXtsConfig(config: XtsConfig): NormalizedXtsConfig;
1310
+
1311
+ /**
1312
+ * Mover collision detection.
1313
+ *
1314
+ * Sub-millimetre accurate for the dominant XTS case: two movers travelling
1315
+ * along the same chain. Each mover occupies a 1D footprint of
1316
+ * `pathLengthMm` centred on `partPositionMm`, so the collision predicate
1317
+ * collapses to one subtraction in path-space:
1318
+ *
1319
+ * centerDistance = arc-length |pos_a − pos_b|
1320
+ * requiredGap = (pathLength_a + pathLength_b) / 2
1321
+ * penetration = requiredGap − centerDistance
1322
+ * collision iff penetration > 0 (≥ 0 for "touching")
1323
+ *
1324
+ * For closed-loop chains (oval tracks, U-turn pairs) the arc-length is
1325
+ * taken as the *minimum* of the forward and the wrap-around path so two
1326
+ * movers near the seam (e.g. 50 mm and trackLength − 50 mm) are treated
1327
+ * as physically adjacent. Open chains are detected by comparing the
1328
+ * chain's start- and end-pose; for those the wrap-around distance is
1329
+ * simply not considered.
1330
+ *
1331
+ * Floating-point precision: positions are kept in mm in float64. A typical
1332
+ * XTS layout has trackLength ≤ 20 m → < 2×10⁴ mm, well inside the
1333
+ * 2⁵³-mantissa range, so subtractive cancellation costs at most ~10⁻¹²
1334
+ * mm — orders of magnitude below sub-millimetre.
1335
+ *
1336
+ * Multi-XPU (different chains): not handled here. Cross-track collisions
1337
+ * are dominated by track-transform layout and would need an OBB / mesh
1338
+ * test in 3D space; that's a different concern from "two movers on the
1339
+ * same line about to crash".
1340
+ */
1341
+
1342
+ /**
1343
+ * One detected pair. Result list is symmetric (a/b is reported once per
1344
+ * pair, with `a` carrying the lower index pair-canonical).
1345
+ */
1346
+ interface MoverCollision {
1347
+ a: MoverRef;
1348
+ b: MoverRef;
1254
1349
  /**
1255
- * `true` when the measurement could not be evaluated (e.g. a
1256
- * `trackDistance` whose endpoints sit on different parts). `value` is then
1257
- * the straight-line fallback.
1350
+ * Stable IDs of the two movers (mirrors `MoverConfig.id`). Useful when
1351
+ * the consumer keys by id rather than by `moverIndex`.
1258
1352
  */
1259
- incomplete?: boolean;
1260
- }
1261
- /** Where an annotation hangs. Mover anchors follow the live mover position. */
1262
- type AnnotationAnchor = {
1263
- kind: 'world';
1264
- positionMm: Vec3;
1265
- } | {
1266
- kind: 'mover';
1267
- ref: MoverRef;
1268
- } | {
1269
- kind: 'module';
1270
- ref: ModuleRef;
1271
- } | ({
1272
- kind: 'trackPosition';
1273
- } & TrackAnchor);
1274
- interface Annotation {
1275
- id: string;
1276
- text: string;
1277
- anchor: AnnotationAnchor;
1353
+ idA: number;
1354
+ idB: number;
1278
1355
  /**
1279
- * Leader-line offset from the anchor to the label (mm, world frame).
1280
- * Default `[0, 0, 250]`.
1356
+ * Penetration along the 1D path:
1357
+ * • `> 0` — movers overlap by this many mm.
1358
+ * • `= 0` — touching exactly.
1359
+ * • `< 0` — closest-approach gap (only present in result when
1360
+ * `warningGapMm` is set; the magnitude is the free-space
1361
+ * distance between the closer footprint edges).
1281
1362
  */
1282
- offsetMm?: Vec3;
1283
- color?: string;
1284
- /** Text height (mm). Default `MeasurementStyle.labelSizeMm`. */
1285
- sizeMm?: number;
1286
- /** Marker drawn at the anchor. Default `'Sphere'`. */
1287
- markerShape?: MarkerShape;
1288
- markerSizeMm?: number;
1289
- /** Default `true`. */
1290
- visible?: boolean;
1291
- meta?: Record<string, unknown>;
1292
- }
1293
- type AnnotationInput = Omit<Annotation, 'id'> & {
1294
- id?: string;
1295
- };
1296
- type MeasurementTool = 'none' | 'distance' | 'angle' | 'path' | 'trackDistance' | 'annotation';
1297
- /** In-progress pick. `null` while no tool is armed. */
1298
- interface MeasurementDraft {
1299
- tool: Exclude<MeasurementTool, 'none'>;
1300
- /** Points committed so far. */
1301
- points: MeasurementPoint[];
1302
- /** Point currently under the cursor, or `null` when off the scene. */
1303
- hover: MeasurementPoint | null;
1304
- }
1305
- interface MeasurementStyle {
1306
- /** Line + label colour of committed measurements. Default `'#00E5FF'`. */
1307
- color?: string;
1308
- /** Colour of the in-progress draft. Default `'#FFC400'`. */
1309
- draftColor?: string;
1310
- /** Annotation colour. Default `'#FFFFFF'`. */
1311
- annotationColor?: string;
1312
- /** Line width in pixels. Default `2`. */
1313
- lineWidthPx?: number;
1314
- /** Text height (mm). Default `40`. */
1315
- labelSizeMm?: number;
1316
- /** Endpoint marker diameter (mm). Default `18`. */
1317
- pointSizeMm?: number;
1318
- /** Decimal places on values. Default `1`. */
1319
- decimals?: number;
1320
- /** Unit suffix appended to lengths. Default `' mm'`. */
1321
- unitSuffix?: string;
1322
- /** Font URL passed to troika (see `DEFAULT_LABEL_FONT_URL`). */
1323
- fontFamily?: string;
1363
+ penetrationMm: number;
1324
1364
  /**
1325
- * Draw measurement lines and markers through solid geometry
1326
- * (`depthTest: false`). Default `true` — CAD viewers keep dimensions
1327
- * readable regardless of what is in front of them.
1365
+ * Both `partPositionMm` values at the time of the test. Lets the
1366
+ * consumer recompute or visualise without re-querying.
1328
1367
  */
1329
- alwaysOnTop?: boolean;
1368
+ positionAMm: number;
1369
+ positionBMm: number;
1370
+ /** Mover lengths (catalog `pathLengthMm`). */
1371
+ pathLengthAMm: number;
1372
+ pathLengthBMm: number;
1373
+ /** True when measurement wrapped around the chain end (closed-loop seam). */
1374
+ viaWraparound: boolean;
1330
1375
  }
1331
- /** Serializable snapshot of everything the measurement feature holds. */
1332
- interface MeasurementDocument {
1333
- schemaVersion: 1;
1334
- measurements: Measurement[];
1335
- annotations: Annotation[];
1376
+ interface CheckMoverCollisionsOptions {
1377
+ /**
1378
+ * When `> 0`, near-misses with closest-approach gap up to this many mm
1379
+ * are also returned (with `penetrationMm < 0`). Default 0 — only actual
1380
+ * collisions / contacts.
1381
+ */
1382
+ warningGapMm?: number;
1383
+ /**
1384
+ * Force-treat the chain as closed (consider wrap-around distance) or
1385
+ * open (don't). When omitted, auto-detected by comparing
1386
+ * `chain.endPose` to `startPose ≈ identity` within ~0.1 mm tolerance.
1387
+ */
1388
+ forceClosedLoop?: boolean;
1336
1389
  }
1337
1390
  /**
1338
- * `measurement` prop of `<XtsViewer3D>`. Data can be driven either
1339
- * declaratively (`measurements` / `annotations` — controlled arrays) or
1340
- * imperatively via `viewerRef.current.measurements`. When the controlled
1341
- * arrays are supplied they win on every render, so a host that uses them must
1342
- * persist what the change callbacks report.
1391
+ * Effective mover state passed into the predicate. Decoupled from
1392
+ * MoverConfig so consumers can plug in live positions from the
1393
+ * MoverPositionStore (60-Hz tick) without round-tripping through React
1394
+ * state.
1343
1395
  */
1344
- interface MeasurementOptions {
1345
- /** Armed tool. Default `'none'` — no picking, no cursor change. */
1346
- tool?: MeasurementTool;
1347
- /** Controlled measurement list. Omit to let the viewer keep its own. */
1348
- measurements?: MeasurementInput[];
1349
- /** Controlled annotation list. Omit to let the viewer keep its own. */
1350
- annotations?: AnnotationInput[];
1351
- snap?: SnapOptions;
1352
- style?: MeasurementStyle;
1353
- /**
1354
- * Z of the fallback pick plane (mm) used when the ray misses all geometry,
1355
- * so free space can be measured. Default `0`; `null` disables the fallback.
1356
- */
1357
- groundPlaneZMm?: number | null;
1358
- /** Fired after every change to the measurement list. */
1359
- onMeasurementsChange?: (measurements: Measurement[], results: MeasurementResult[]) => void;
1360
- /** Fired once when a tool completes a measurement. */
1361
- onMeasurementCreate?: (measurement: Measurement, result: MeasurementResult) => void;
1362
- onAnnotationsChange?: (annotations: Annotation[]) => void;
1363
- onAnnotationCreate?: (annotation: Annotation) => void;
1364
- /** Fired on every draft change (point added, hover moved, cancelled). */
1365
- onDraftChange?: (draft: MeasurementDraft | null) => void;
1396
+ interface MoverProbe {
1397
+ ref: MoverRef;
1398
+ id: number;
1399
+ partOid: number;
1400
+ moverType: MoverType3D;
1401
+ partPositionMm: number;
1366
1402
  /**
1367
- * Text for an annotation the `'annotation'` tool just placed. Return
1368
- * `null` to abort. Default: `'Annotation'`.
1403
+ * Override the catalog `pathLengthMm`. Use for `Custom` movers where
1404
+ * the layout's `pathLengthMm` is the source of truth.
1369
1405
  */
1370
- annotationTextFactory?: (point: MeasurementPoint) => string | null;
1406
+ pathLengthMm?: number;
1371
1407
  }
1372
-
1373
1408
  /**
1374
- * `XtsModelDocument` — the canonical serialized form of a viewer scene.
1409
+ * Pair-wise 1D collision test for movers on the same chain.
1375
1410
  *
1376
- * What `viewerRef.current.exportModel()` produces and what a host persists:
1377
- * the configuration, optionally the runtime mover state, and optionally the
1378
- * measurements. The measurement half keeps its own types next to its
1379
- * implementation (`measurement/types.ts`); only the serialized document
1380
- * surfaces here.
1381
- */
1382
-
1383
- interface XtsModelDocument {
1384
- schemaVersion: 1;
1385
- meta: {
1386
- generatedAt: string;
1387
- generator: string;
1388
- sourceFormat?: string;
1389
- sourceFileHash?: string;
1390
- };
1391
- config: XtsConfig;
1392
- runtime?: XtsRuntimeState;
1393
- railSystem?: RailSystem | {
1394
- byProcessingUnit: Record<number, RailSystem>;
1395
- };
1396
- /**
1397
- * Measurements + annotations placed on the layout, as returned by
1398
- * `XtsViewer3DRef.measurements.toJSON()`. Omitted when there are none, so
1399
- * documents written before the measurement feature stay byte-identical.
1400
- */
1401
- measurements?: MeasurementDocument;
1402
- }
1403
- interface XtsRuntimeState {
1404
- movers: Array<{
1405
- processingUnitObjectId: number;
1406
- moverIndex: number;
1407
- id: number;
1408
- partOid: number;
1409
- partPositionMm: number;
1410
- velocity?: {
1411
- mmPerSec: number;
1412
- };
1413
- status?: {
1414
- warning?: boolean;
1415
- error?: boolean;
1416
- };
1417
- }>;
1418
- selection?: SelectionState;
1419
- camera?: CameraState;
1420
- display?: Partial<DisplayOptions>;
1421
- }
1411
+ * `chain` is the BuiltChain corresponding to the partOid all probed
1412
+ * movers travel on. `probes` may contain movers from *any* part — this
1413
+ * function only considers pairs where `partOid` matches the chain.
1414
+ *
1415
+ * Returns one entry per colliding (or near-miss, when `warningGapMm > 0`)
1416
+ * pair, sorted by `penetrationMm` descending so the deepest collisions
1417
+ * appear first.
1418
+ */
1419
+ declare function checkSamePathCollisions(chain: BuiltChain, partOid: number, probes: ReadonlyArray<MoverProbe>, opts?: CheckMoverCollisionsOptions): MoverCollision[];
1420
+ /**
1421
+ * Collision check across a whole config: build a probe per mover, group by
1422
+ * part, and run `checkSamePathCollisions` on each group with two or more.
1423
+ *
1424
+ * `livePositionOf` supplies the current position of a mover when one is
1425
+ * being driven imperatively — the `MoverPositionStore` at 60 Hz — falling
1426
+ * back to the config value. Passing it as a function rather than the store
1427
+ * itself is what keeps this a pure geometry function: it was previously
1428
+ * trapped inside the `useImperativeHandle` closure of `useXtsViewerHandle`,
1429
+ * where it could only be exercised through a mounted canvas.
1430
+ *
1431
+ * Results are sorted so the deepest collision in the scene comes first.
1432
+ */
1433
+ declare function checkAllMoverCollisions(config: NormalizedXtsConfig, livePositionOf: (moverId: number) => number | undefined, opts?: CheckMoverCollisionsOptions): MoverCollision[];
1422
1434
 
1423
1435
  /**
1424
- * SidecarLoader — fetches per-asset JSON sidecars.
1436
+ * Module collision / overlap detection.
1425
1437
  *
1426
- * Sidecars carry origin-correction (translateMm + rotationDegEuler) plus
1427
- * path metadata. They live next to the GLB at <baseUrl>/<id>.meta.json (or
1428
- * <id>.tool.meta.json for mover tools).
1438
+ * The mover collision (moverCollision.ts) is a 1D arc-length test for two
1439
+ * movers on the *same* chain; its header explicitly declines cross-XPU /
1440
+ * cross-track cases, which "would need an OBB / mesh test in 3D space".
1441
+ * This file is exactly that test, for the physical track *modules*.
1429
1442
  *
1430
- * On 404 the loader falls back to default origin-correction `(0,0,0)` /
1431
- * `(0,0,0)`. The renderer can still place the GLB; the calibration tool is
1432
- * the authoritative source for sidecar values.
1443
+ * Each module is modelled as a chain of oriented bounding boxes (OBBs) that
1444
+ * follow its real centerline: the path is sampled (sampleModulePath) and each
1445
+ * segment becomes a small box (±halfWidth in Y, 0..height in Z) oriented along
1446
+ * the local tangent, then transformed into world space by
1447
+ *
1448
+ * world = orientation ⊗ trackTransform ⊗ partTransformation ⊗ chainPose
1449
+ *
1450
+ * Sampling the path (rather than a single straight box per module) is what
1451
+ * keeps curves and U-turns faithful — a single straight box would poke
1452
+ * outside the arc and produce both false positives and false negatives on a
1453
+ * closed oval. Straights collapse to one box; curves / free paths use several.
1454
+ *
1455
+ * Pairs are tested with the Separating Axis Theorem (15 axes) over their
1456
+ * segment boxes, yielding a penetration depth (minimum translation distance)
1457
+ * as `overlapMm`:
1458
+ *
1459
+ * overlapMm > 0 — boxes overlap by this many mm (deepest sub-box pair).
1460
+ * overlapMm = 0 — touching exactly.
1461
+ * overlapMm < 0 — closest-approach gap (only reported when
1462
+ * `warningGapMm` is set).
1463
+ *
1464
+ * Adjacent modules in the same part butt up edge-to-edge (ChainBuilder), so
1465
+ * consecutive `(i, i+1)` pairs — and the `(N-1, 0)` seam of a closed loop —
1466
+ * are excluded; everything else (cross-part, cross-XPU, same-part
1467
+ * non-adjacent) is tested.
1468
+ *
1469
+ * The box height comes from the module's own GLB sidecar
1470
+ * (`moduleHeightSpan`, moduleCrossSection.ts): a motor module measures about
1471
+ * 40 mm and straddles the path plane. It used to be one coarse box per type,
1472
+ * `0 … 100` mm and entirely above the plane, which reported an overlap between
1473
+ * any two tracks stacked closer than 100 mm — the arrangement a doublesided
1474
+ * guide rail is built as. The half-width stays the coarse `halfWidthMm` 50,
1475
+ * because a curved module's sidecar box spans its whole arc in X and Y and is
1476
+ * a cross-section only in Z; moduleCrossSection.ts sets that out.
1477
+ *
1478
+ * The `warningGapMm` near-miss is an axis-projected SAT separation, not a true
1479
+ * Euclidean (GJK) distance — slightly conservative for corner-to-corner cases.
1433
1480
  */
1434
1481
 
1435
- interface OriginCorrection {
1436
- translateMm: Vec3;
1437
- rotationDegEuler: Vec3;
1482
+ /** Default box half-width (Y) — matches `approximateModuleBounds`. */
1483
+ declare const DEFAULT_MODULE_HALF_WIDTH_MM = 50;
1484
+ /** Default box height (Z) — matches `approximateModuleBounds`. */
1485
+ declare const DEFAULT_MODULE_HEIGHT_MM = 100;
1486
+ /**
1487
+ * One detected pair. Symmetric: `a`/`b` are canonicalised so the lower
1488
+ * `(processingUnitObjectId, partObjectId, moduleIndex)` tuple is always `a`.
1489
+ */
1490
+ interface ModuleCollision {
1491
+ a: ModuleRef;
1492
+ b: ModuleRef;
1493
+ moduleTypeA: ModuleType3D;
1494
+ moduleTypeB: ModuleType3D;
1495
+ /** Forwarded `ModuleEntry.globalNumber` (status-store key), if present. */
1496
+ globalNumberA?: number;
1497
+ globalNumberB?: number;
1498
+ /**
1499
+ * Penetration depth along the minimum-translation axis of the deepest
1500
+ * sub-box pair:
1501
+ * • `> 0` — overlap (mm).
1502
+ * • `= 0` — touching.
1503
+ * • `< 0` — closest-approach gap (only when `warningGapMm` is set).
1504
+ */
1505
+ overlapMm: number;
1506
+ /** Centre-to-centre distance of the deepest sub-box pair (mm). */
1507
+ centerDistanceMm: number;
1508
+ /** Unit world-space axis of least penetration (the MTV direction). */
1509
+ axis: Vec3;
1438
1510
  }
1439
- interface ModuleSidecar {
1511
+ interface CheckModuleCollisionsOptions {
1512
+ /**
1513
+ * When `> 0`, near-misses with an axis-projected gap up to this many mm
1514
+ * are also returned (with `overlapMm < 0`). Default 0 — only actual
1515
+ * overlaps / contacts.
1516
+ */
1517
+ warningGapMm?: number;
1518
+ /**
1519
+ * Also test non-adjacent module pairs within the SAME part. Default true.
1520
+ * Consecutive `(i, i+1)` pairs and the closed-loop `(N-1, 0)` seam are
1521
+ * ALWAYS excluded regardless of this flag.
1522
+ */
1523
+ includeSamePart?: boolean;
1524
+ /** Box half-width in Y (mm). Default 50. */
1525
+ halfWidthMm?: number;
1526
+ /**
1527
+ * Force one box height in Z (mm), spanning `0 … heightMm` above the path
1528
+ * plane for every module type.
1529
+ *
1530
+ * Left unset — the default — each module is given the vertical extent its
1531
+ * GLB sidecar reports (`moduleHeightSpan`), which both is the real height
1532
+ * and sits on the real side of the path plane. Setting this restores the one
1533
+ * coarse box the check used before that, for a caller who wants a
1534
+ * deliberately conservative envelope.
1535
+ */
1536
+ heightMm?: number;
1537
+ }
1538
+ /** Oriented bounding box in world space (unit axes + scaled half-extents). */
1539
+ interface Obb {
1540
+ center: Vector3;
1541
+ axes: [Vector3, Vector3, Vector3];
1542
+ halfExtents: [number, number, number];
1543
+ /** Bounding-sphere radius (broad-phase reject). */
1544
+ radius: number;
1545
+ }
1546
+ /** Pre-built per-module probe consumed by `checkModuleCollisions`. */
1547
+ interface ModuleProbe {
1548
+ ref: ModuleRef;
1440
1549
  moduleType: ModuleType3D;
1441
- glbByRailSystem: {
1442
- Beckhoff: string | null;
1443
- HepcoGfx?: string | null;
1444
- };
1445
- originCorrection: OriginCorrection;
1446
- pathType: 'Straight' | 'Curve' | 'Free';
1447
- moduleLengthMm: number;
1448
- endAngleDeg: number;
1449
- freePathFile?: string;
1450
- approximateBoundsMm?: {
1451
- min: Vec3;
1452
- max: Vec3;
1453
- };
1550
+ globalNumber?: number;
1551
+ /** Adjacency grouping key (part.globalNumber — system-wide unique). */
1552
+ partKey: number;
1553
+ /** Index of this module within its part's chain. */
1554
+ moduleIndex: number;
1555
+ /** Segment boxes following the module centerline (≥ 1). */
1556
+ obbs: Obb[];
1557
+ /** Module-level bounding sphere enclosing all segment boxes. */
1558
+ sphereCenter: Vector3;
1559
+ sphereRadius: number;
1454
1560
  }
1455
- interface MoverSidecar {
1456
- moverType: MoverType3D;
1457
- glb: string;
1458
- originCorrection: OriginCorrection;
1459
- magnetPlateCenterMm: Vec3;
1460
- pathLengthMm: number;
1561
+ /** A part fed to `buildModuleProbes`. */
1562
+ interface ModuleCollisionPartInput {
1563
+ /** ModuleRef.partObjectId (`PartConfig.objectId`). */
1564
+ partObjectId: number;
1565
+ /** Adjacency key (`PartConfig.globalNumber`). */
1566
+ partKey: number;
1567
+ partTransformation?: PartTransformation | null;
1568
+ chain: BuiltChain;
1461
1569
  }
1462
- interface MoverToolSidecar {
1463
- toolType: MoverToolType3D;
1464
- glbUrl: string;
1465
- originCorrection: OriginCorrection;
1466
- defaultOffsetMm: Vec3;
1467
- approximateBoundsMm?: {
1468
- min: Vec3;
1469
- max: Vec3;
1470
- };
1471
- label: string;
1570
+ /** An XPU fed to `buildModuleProbes`. */
1571
+ interface ModuleCollisionXpuInput {
1572
+ xpuObjectId: number;
1573
+ /** Effective (override-resolved) per-XPU track transform. */
1574
+ trackTransform?: TrackTransform | null;
1575
+ parts: ReadonlyArray<ModuleCollisionPartInput>;
1472
1576
  }
1473
1577
  /**
1474
- * Calibration sidecar for a guiding-rail GLB. The rail is rendered in the
1475
- * same module-local frame as the module (i.e. under the module's
1476
- * `startWorldPose`) with this correction applied to the GLB scene.
1578
+ * Turn a list of XPUs (with effective track transforms) + parts (with built
1579
+ * chains) into flat module probes plus a per-part adjacency map. Shared by
1580
+ * the imperative ref and the reactive monitor so the world-matrix
1581
+ * composition lives in one place.
1582
+ *
1583
+ * Hidden XPUs / parts (`visible === false`) are skipped to match the scene.
1477
1584
  */
1478
- interface RailSidecar {
1479
- railType: RailType3D;
1480
- glb: string;
1481
- originCorrection: OriginCorrection;
1482
- approximateBoundsMm?: {
1483
- min: Vec3;
1484
- max: Vec3;
1485
- };
1486
- }
1487
- declare const DEFAULT_ORIGIN_CORRECTION: OriginCorrection;
1585
+ declare function buildModuleProbes(orientation: Orientation | undefined, xpus: ReadonlyArray<ModuleCollisionXpuInput>, opts?: CheckModuleCollisionsOptions): {
1586
+ probes: ModuleProbe[];
1587
+ adjacency: Map<number, {
1588
+ count: number;
1589
+ closed: boolean;
1590
+ }>;
1591
+ };
1488
1592
  /**
1489
- * Generic sidecar fetch with promise caching + graceful fallback. Returns
1490
- * `undefined` on 404 (caller substitutes a default), and surfaces network or
1491
- * parse errors as `Error` (caller is expected to forward to onError).
1593
+ * Pairwise OBB overlap test over all module probes. Same-part consecutive
1594
+ * and closed-loop-seam pairs are excluded; everything else is tested.
1595
+ * Returns one entry per overlapping (or near-miss, when `warningGapMm > 0`)
1596
+ * pair, sorted deepest-overlap-first.
1492
1597
  */
1493
- declare class SidecarLoader {
1494
- private readonly timeoutMs;
1495
- private readonly cache;
1496
- constructor(timeoutMs?: number);
1497
- clear(): void;
1498
- /**
1499
- * Fetch and cache the sidecar at `url`. The loader validates the one field
1500
- * every sidecar shape shares (`originCorrection`) and hands the rest to the
1501
- * caller as `T` — the kind is a property of the URL, not of the loader.
1502
- */
1503
- fetch<T>(url: string): Promise<T | undefined>;
1504
- }
1505
- /**
1506
- * Resolve the effective origin correction for a module sidecar — falls back
1507
- * to identity when the sidecar is absent.
1508
- */
1509
- declare function resolveOriginCorrection(sidecar: {
1510
- originCorrection?: OriginCorrection;
1511
- } | undefined): OriginCorrection;
1512
- /** Standard sidecar filename from a base id. */
1513
- declare function moduleSidecarFilename(moduleType: ModuleType3D): string;
1514
- declare function moverSidecarFilename(moverType: MoverType3D): string;
1515
- declare function toolSidecarFilename(toolType: MoverToolType3D): string;
1516
- declare function railSidecarFilename(railType: RailType3D): string;
1517
- /** Anything that names a GLB per rail system — a sidecar or a manifest entry. */
1518
- interface GlbByRailSystem {
1519
- glbByRailSystem: {
1520
- Beckhoff: string | null;
1521
- HepcoGfx?: string | null;
1522
- };
1523
- }
1598
+ declare function checkModuleCollisions(probes: ReadonlyArray<ModuleProbe>, adjacency: ReadonlyMap<number, {
1599
+ count: number;
1600
+ closed: boolean;
1601
+ }>, opts?: CheckModuleCollisionsOptions): ModuleCollision[];
1524
1602
  /**
1525
- * Pure helper: pick the GLB filename with rail-system fallback. Returns null
1526
- * when the entry marks the module 'prepared but not delivered'.
1603
+ * Resolve a normalized config into the per-XPU inputs `buildModuleProbes`
1604
+ * takes, applying the same override precedence the scene uses: an imperative
1605
+ * `trackTransformOverrides` entry beats the config value.
1527
1606
  *
1528
- * Takes the structural shape rather than `ModuleSidecar` so
1529
- * `resolveModuleGlbUrl`, which used to carry a line-for-line identical copy
1530
- * of this body over manifest entries, can delegate here. Both are public
1531
- * API, so both stay — but there is one implementation of the fallback rule.
1607
+ * This adapter was written out twice, character for character — once in
1608
+ * `<ModuleCollisionMonitor>` and once inside the `checkModuleCollisions`
1609
+ * closure of `useXtsViewerHandle` — with the only difference being where the
1610
+ * orientation came from.
1611
+ *
1612
+ * A part whose chain cannot be built is skipped, as before, but no longer in
1613
+ * silence: `normalizeXtsConfig` has already made that impossible, so if it
1614
+ * ever happens it means a part has silently dropped out of collision
1615
+ * detection, which is a safety feature. Reported rather than thrown, because
1616
+ * both callers run inside React and a throw would take the scene down.
1532
1617
  */
1533
- declare function pickGlbForRail(entry: GlbByRailSystem | undefined, rail: RailSystem): string | null;
1618
+ declare function toModuleCollisionInputs(config: NormalizedXtsConfig, trackTransformOverrides?: Record<number, TrackTransform>): ModuleCollisionXpuInput[];
1619
+ /** `toModuleCollisionInputs` → `buildModuleProbes` → `checkModuleCollisions`. */
1620
+ declare function checkAllModuleCollisions(config: NormalizedXtsConfig, orientation: Orientation | undefined, trackTransformOverrides?: Record<number, TrackTransform>, opts?: CheckModuleCollisionsOptions): ModuleCollision[];
1534
1621
 
1535
1622
  /**
1536
- * Option shapes shared between the public `<XtsViewer3D>` props and the
1537
- * internal `<XtsScene>` / `<ProcessingUnitRoot>` props.
1623
+ * Interaction surface — selection, highlighting and the camera.
1538
1624
  *
1539
- * Each of these used to be declared twice — once on each side of that
1540
- * boundary, character for character — so the two could drift silently, and
1541
- * `SidecarOverrides` was even exported under two public names. Declared once
1542
- * here and imported by both.
1625
+ * The types a host exchanges with the viewer at runtime rather than at
1626
+ * configuration time: what is addressed (`ModuleRef`, `MoverRef`), what is
1627
+ * picked out (`SelectionState`, `ModuleHighlight`, `FeedSegmentHighlight`),
1628
+ * and where the camera looks (`CameraState`, `FocusTarget`).
1543
1629
  */
1544
1630
 
1545
- /** Where the view cube sits in the canvas. */
1546
- type ViewCubeAlignment = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center' | 'center-left' | 'center-right' | 'center-center';
1547
- interface ViewCubeOptions {
1548
- enabled?: boolean;
1549
- /** Default `'bottom-right'`. */
1550
- alignment?: ViewCubeAlignment;
1551
- /** Distance from the aligned corner, in CSS px. Default `[80, 80]`. */
1552
- marginPx?: [number, number];
1631
+ interface ModuleRef {
1632
+ processingUnitObjectId: number;
1633
+ partObjectId: number;
1634
+ moduleIndex: number;
1553
1635
  }
1554
- /** Interaction locks. Each `true` disables that interaction. */
1555
- interface InteractionLocks {
1556
- rotate?: boolean;
1557
- pan?: boolean;
1558
- zoom?: boolean;
1559
- selection?: boolean;
1636
+ interface MoverRef {
1637
+ processingUnitObjectId: number;
1638
+ moverIndex: number;
1560
1639
  }
1561
- interface PerformanceOptions {
1562
- /** Cap the frameloop rate. Unset = uncapped. */
1563
- maxFps?: number;
1564
- /**
1565
- * Draw a processing unit's movers as one `InstancedMesh` instead of one
1566
- * scene node each, cutting mover draw calls from N to 1. Default: false.
1567
- *
1568
- * Applies from four movers up, and only to movers with nothing bound to
1569
- * them: a mover carrying tools, a mover-bound custom asset, or a visible
1570
- * drive status still gets its own node. Turning on the anchor marker or
1571
- * mover-id labels gives every mover a child object, so the batch is
1572
- * skipped for that unit entirely. Selection and clicks work either way.
1573
- */
1574
- instancing?: boolean;
1575
- /** Stop the frameloop while the tab is hidden. Default: true. */
1576
- autoPauseOnHidden?: boolean;
1577
- /**
1578
- * Enable demand rendering: frames only render when something changes
1579
- * (camera move, mover position update, selection). Saves >90% GPU time
1580
- * when the scene is idle. Default: true.
1581
- */
1582
- demandRendering?: boolean;
1583
- /**
1584
- * Cap the device pixel ratio. Values above 2 rarely improve perceptible
1585
- * quality but double fill-rate cost on Retina/4K displays. Default: 2.
1586
- */
1587
- maxDpr?: number;
1588
- /**
1589
- * Disable automatic per-frame shadow map updates. Shadows only re-render
1590
- * on camera change. Default: true (when shadows enabled).
1591
- */
1592
- shadowOnDemand?: boolean;
1640
+ /**
1641
+ * Addresses a whole track — one processing unit with everything anchored to
1642
+ * it: its modules, movers, tools and overlays.
1643
+ */
1644
+ interface TrackRef {
1645
+ processingUnitObjectId: number;
1646
+ }
1647
+ /**
1648
+ * Addresses one entry of `XtsConfig.customAssets` by its `id`. Only assets
1649
+ * with a `'static'` binding are pickable — a mover-bound asset rides its
1650
+ * mover and is selected by selecting that mover.
1651
+ */
1652
+ interface CustomAssetRef {
1653
+ assetId: string;
1654
+ }
1655
+ interface SelectionState {
1656
+ modules: ModuleRef[];
1657
+ movers: MoverRef[];
1593
1658
  /**
1594
- * Opt into the WebGPU renderer when the browser supports it. Falls back to
1595
- * WebGL automatically. Default: false (experimental).
1659
+ * Selected whole tracks — what a click produces under
1660
+ * `selectionScope: 'track'`. A selected track counts as selecting
1661
+ * everything on it: `isModuleSelected` and `isMoverSelected` both answer
1662
+ * `true` for its contents, so the whole track tints as one.
1596
1663
  */
1597
- webgpu?: boolean;
1664
+ tracks?: TrackRef[];
1598
1665
  /**
1599
- * Override the KTX2 (Basis) transcoder path used to decode compressed
1600
- * textures in the GLB assets. Defaults to a CDN copy pinned to the bundled
1601
- * three.js revision. Point this at a self-hosted
1602
- * `basis_transcoder.{js,wasm}` directory for offline / air-gapped use.
1666
+ * Selected static custom assets. Optional so a host that only ever
1667
+ * selects modules and movers keeps building the object it always did.
1603
1668
  */
1604
- ktx2TranscoderUrl?: string;
1669
+ customAssets?: CustomAssetRef[];
1605
1670
  }
1606
- /**
1607
- * Live override of the per-asset origin-correction sidecars, keyed by asset
1608
- * type. Useful for calibration tooling that adjusts a model's origin without
1609
- * re-publishing its `.meta.json`.
1610
- */
1611
- interface SidecarOverrides {
1612
- modules?: Partial<Record<ModuleType3D, OriginCorrection>>;
1613
- movers?: Partial<Record<MoverType3D, OriginCorrection>>;
1614
- tools?: Partial<Record<MoverToolType3D, OriginCorrection>>;
1615
- rails?: Partial<Record<RailType3D, OriginCorrection>>;
1671
+ /** Per-entity selection highlight colours. */
1672
+ interface SelectionColors {
1673
+ module?: string;
1674
+ mover?: string;
1675
+ customAsset?: string;
1616
1676
  }
1617
-
1677
+ type SelectionMode = 'Off' | 'Single' | 'Multi';
1618
1678
  /**
1619
- * Module catalog (3D-canonical).
1679
+ * What a click into the track picks out.
1620
1680
  *
1621
- * Every module declares its path topology (Straight / Curve / Free), its
1622
- * arc length, and — for curves — the signed end-of-module yaw delta in
1623
- * degrees. Centerline radius for analytic curves is derived from
1624
- * `R = L / |θ_rad|`.
1681
+ * - `'module'` (default) — the module or mover clicked, as before.
1682
+ * - `'track'` — the whole processing unit it belongs to, whichever of its
1683
+ * parts was hit. Individual modules and movers cannot be selected in
1684
+ * this scope at all.
1625
1685
  *
1626
- * Hygienic ATH variants share path math with their AT pendants — only the
1627
- * GLB mesh differs.
1686
+ * A static custom asset is always selected in its own right: it stands free
1687
+ * in the scene rather than on a track.
1628
1688
  *
1629
- * This file is hand-curated. The geometry module imports from here directly
1630
- * (no JSON fetch) so the library has zero runtime data deps.
1689
+ * This is what the transform gizmo follows. A module has no pose of its own
1690
+ * — modules bolt together end-to-end along the chain — so a module
1691
+ * selection never grows handles; a track selection does.
1631
1692
  */
1632
-
1633
- type PathType = 'Straight' | 'Curve' | 'Free';
1634
- interface ModuleCatalogEntry {
1635
- moduleType: ModuleType3D;
1636
- /** Arc length along the centerline, in mm. */
1637
- moduleLengthMm: number;
1638
- pathType: PathType;
1639
- /**
1640
- * Encoder-side end-of-module yaw, in degrees, in the module-local 3D frame.
1641
- * Sign convention: positive endAngle ⇒ Y-flip math curves toward
1642
- * −Y (= right of path). 0 for Straight. 180 for AT2050_*_180 free path.
1643
- */
1644
- endAngleDeg: number;
1645
- /**
1646
- * Centerline radius for analytic curves, in mm. `null` for Straight + Free.
1647
- * Computed as R = L / |θ_rad|; equals 636.6198 for ±22.5° and 318.3099 for
1648
- * ±45° curves at L=250.
1649
- */
1650
- centerlineRadiusMm: number | null;
1651
- /**
1652
- * Free-path point cloud reference. Only set for `pathType==='Free'`.
1653
- * The string identifies the cloud by name; the actual data lives in
1654
- * geometry/PointClouds.ts.
1655
- */
1656
- freePathId?: string;
1657
- /** True if STP is not delivered yet. */
1658
- prepared?: boolean;
1659
- /** True for Einspeisemodule (power feed-in variants). */
1660
- isInfeed?: boolean;
1693
+ type SelectionScope = 'module' | 'track';
1694
+ /**
1695
+ * Declarative module highlight. All modules whose `moduleType` appears in
1696
+ * `moduleTypes` are tinted with the given `color` — same emissive effect as
1697
+ * selection, but independent of the selection state. Multiple highlights can
1698
+ * overlap; the first matching entry wins.
1699
+ *
1700
+ * Toggle on/off by setting `moduleHighlights` on `<XtsViewer3D>` to an
1701
+ * array (enabled) or `undefined` / `[]` (disabled).
1702
+ */
1703
+ interface ModuleHighlight {
1704
+ /** Module types to highlight. */
1705
+ moduleTypes: ModuleType3D[];
1706
+ /** Highlight color (CSS color string, e.g. `'#FF6600'`). */
1707
+ color: string;
1661
1708
  }
1662
- declare const MODULE_CATALOG: Readonly<Record<ModuleType3D, ModuleCatalogEntry>>;
1663
1709
  /**
1664
- * Beckhoff XTS infeed module types (Einspeisemodule). Convenience constant
1665
- * for use with `moduleHighlights` on `<XtsViewer3D>`.
1710
+ * Addresses one feed segment (Einspeisestrang). Either form works — pick
1711
+ * whichever the host has at hand:
1666
1712
  *
1667
- * Derived from the catalog (`isInfeed` flag) so it never drifts out of sync —
1668
- * mark a new module with `isInfeed: true` in `MODULE_CATALOG` and it is
1669
- * automatically included here.
1713
+ * - `{ infeedModuleGlobalNumber }` — the `ModuleEntry.globalNumber` of the
1714
+ * infeed module that leads the segment. Stable across layout edits as long
1715
+ * as the global numbering is stable.
1716
+ * - `{ partObjectId, segmentIndex }` — ordinal address: `segmentIndex` counts
1717
+ * 0, 1, 2 … in chain order, starting at the part's first infeed module.
1670
1718
  */
1671
- declare const INFEED_MODULE_TYPES: readonly ModuleType3D[];
1672
- /** Lookup with type-narrowing. Returns undefined for unknown types. */
1673
- declare function getModuleEntry(moduleType: ModuleType3D): ModuleCatalogEntry | undefined;
1674
- /** Type-guard for safe lookups from untyped (e.g. JSON) sources. */
1675
- declare function isKnownModuleType(value: string): value is ModuleType3D;
1676
-
1719
+ type FeedSegmentSelector = {
1720
+ infeedModuleGlobalNumber: number;
1721
+ } | {
1722
+ partObjectId: number;
1723
+ segmentIndex: number;
1724
+ };
1677
1725
  /**
1678
- * ChainBuilder — builds the per-module `startWorldMatrix` for a Part.
1726
+ * Declarative feed-segment highlight. Every module belonging to one of the
1727
+ * selected segments is tinted with `color` — same emissive effect as
1728
+ * selection, but independent of the selection state.
1679
1729
  *
1680
- * Chain composition (simplified — modules butt up edge-to-edge in this build):
1681
- * chain_world = Identity
1682
- * for each module_i:
1683
- * module_i.startWorldMatrix = chain_world
1684
- * module_i.startPositionInPartMm = cumulative
1685
- * module_i.trackLengthMm = module_i.moduleLengthMm
1686
- * cumulative += trackLengthMm
1687
- * end_local = T( ΔX, -ΔY_canvas, 0 ) ⊗ R_z( -θ )
1688
- * chain_world = chain_world ⊗ end_local
1730
+ * Multiple entries are allowed (that is how several segments get several
1731
+ * colors at once); on overlap the first matching entry wins, mirroring
1732
+ * `ModuleHighlight`.
1689
1733
  *
1690
- * `chain_world` is a planar pose: 2D translation (X,Y) + Z-yaw. We carry it as
1691
- * a tiny struct rather than a full 4×4 to keep the pure-math layer free of
1692
- * Three.js. A Three.js helper in PathBuilder.ts converts to Matrix4 on demand.
1734
+ * Toggle on/off by setting `feedSegmentHighlights` on `<XtsViewer3D>` to an
1735
+ * array (enabled) or `undefined` / `[]` (disabled).
1693
1736
  */
1694
-
1695
- /** Planar pose (X,Y,yaw) in a 3D-RH-Z-up frame. Z is always 0 here. */
1696
- interface PlanarPose {
1697
- positionMm: Vec3;
1698
- yawDeg: number;
1737
+ interface FeedSegmentHighlight {
1738
+ /** Segments to tint. */
1739
+ segments: FeedSegmentSelector[];
1740
+ /** Highlight color (CSS color string, e.g. `'#00AAFF'`). */
1741
+ color: string;
1699
1742
  }
1700
- declare const IDENTITY_POSE: PlanarPose;
1701
- interface BuiltModule {
1702
- moduleType: ModuleType3D;
1703
- catalog: ModuleCatalogEntry;
1704
- /** Pose of the module-local origin (= path entry). */
1705
- startWorldPose: PlanarPose;
1706
- /** Distance along the part where this module starts (mm). */
1707
- startPositionInPartMm: number;
1708
- /** moduleLength (mm). Used for findModuleContaining. */
1709
- trackLengthMm: number;
1710
- /** Forwarded from `ModuleEntry.globalNumber` — index into the runtime
1711
- * `ModuleStatusStore` so renderers can subscribe per module. */
1712
- globalNumber?: number;
1713
- /** Forwarded from `ModuleEntry.status` so the renderer can light up
1714
- * the per-module drive-status overlay without an extra lookup. */
1715
- status?: {
1716
- warning?: boolean;
1717
- error?: boolean;
1743
+ /**
1744
+ * One feed segment (Einspeisestrang) as detected in the configuration: the
1745
+ * contiguous run of modules starting at an infeed module (Einspeisemodul,
1746
+ * `ModuleCatalogEntry.isInfeed`) and ending right before the next infeed
1747
+ * module of the same part.
1748
+ *
1749
+ * On a closed chain (oval, U-turn pair — see `isChainClosed`) the modules
1750
+ * ahead of the part's first infeed module belong to the LAST segment, which
1751
+ * then carries `wrapsSeam: true`. On an open chain those leading modules
1752
+ * belong to no segment at all and cannot be tinted.
1753
+ *
1754
+ * Returned by `XtsViewer3DRef.getFeedSegments()`.
1755
+ */
1756
+ interface FeedSegment {
1757
+ processingUnitObjectId: number;
1758
+ partObjectId: number;
1759
+ /** 0-based, in chain order, counted from the part's first infeed module. */
1760
+ segmentIndex: number;
1761
+ /**
1762
+ * `ModuleEntry.globalNumber` of the leading infeed module. `undefined` when
1763
+ * the config does not set it — such a segment can only be addressed via
1764
+ * `{ partObjectId, segmentIndex }`.
1765
+ */
1766
+ infeedModuleGlobalNumber?: number;
1767
+ /** Module type of the leading infeed module. */
1768
+ infeedModuleType: ModuleType3D;
1769
+ /** Chain indices of every module in this segment, in chain order. */
1770
+ moduleIndices: number[];
1771
+ /** Σ of the segment's module lengths, in mm. */
1772
+ lengthMm: number;
1773
+ /** True when the segment wraps the closed-loop seam. */
1774
+ wrapsSeam: boolean;
1775
+ }
1776
+ interface CameraState {
1777
+ positionMm: Vec3;
1778
+ targetMm: Vec3;
1779
+ /** Always 'Z' in the current spec. */
1780
+ upAxis: 'Z' | 'Y';
1781
+ zoom?: number;
1782
+ }
1783
+ /**
1784
+ * Live camera projection.
1785
+ * - `'perspective'` (default) — the regular 3D view.
1786
+ * - `'orthographic'` — gives the flat 2D plan view
1787
+ * that matches `exportScreenshot({ mode: 'top-down' })`.
1788
+ */
1789
+ type CameraProjection = 'perspective' | 'orthographic';
1790
+ /**
1791
+ * Target for the imperative `viewerRef.current.focusOn(target, opts)` camera
1792
+ * animation. The viewer computes the target's world-space bounding box and
1793
+ * flies the camera so the whole object fits the frame:
1794
+ * - In `'perspective'` projection the current view angle is preserved
1795
+ * (the camera only dollies / re-centres).
1796
+ * - In `'orthographic'` top-down the frustum is re-framed and the camera
1797
+ * pans straight over the object.
1798
+ */
1799
+ type FocusTarget = {
1800
+ kind: 'scene';
1801
+ } | {
1802
+ kind: 'station';
1803
+ stationId: number;
1804
+ } | {
1805
+ kind: 'area';
1806
+ areaId: number;
1807
+ } | {
1808
+ kind: 'mover';
1809
+ ref: MoverRef;
1810
+ } | {
1811
+ kind: 'module';
1812
+ ref: ModuleRef;
1813
+ };
1814
+ interface FocusOptions {
1815
+ /** Animation duration in ms. Default 700. `0` jumps immediately. */
1816
+ durationMs?: number;
1817
+ /**
1818
+ * Padding around the target's bounding box (1 = tight fit). Default 1.2 in
1819
+ * perspective, 1.1 in orthographic top-down.
1820
+ */
1821
+ paddingFactor?: number;
1822
+ /** Easing curve. Default `'easeInOutCubic'`. */
1823
+ easing?: 'linear' | 'easeInOutCubic';
1824
+ }
1825
+ /**
1826
+ * Collision monitoring.
1827
+ *
1828
+ * Movers and modules are watched by different machinery — movers move at
1829
+ * 60 Hz through the position store and are checked in `useFrame`, modules
1830
+ * only move when the configuration or a `trackTransform` changes and are
1831
+ * checked in an effect — but from the outside they configure the same way,
1832
+ * which is why they now sit under one prop.
1833
+ */
1834
+ interface CollisionOptions {
1835
+ /** Continuous mover collision monitoring. */
1836
+ movers?: MoverCollisionOptions;
1837
+ /**
1838
+ * Static MODULE overlap monitoring. Detects track modules that physically
1839
+ * overlap in 3D space — different XPUs / parts placed via `trackTransform`
1840
+ * / `orientation` / `partTransformation`, plus non-adjacent modules of the
1841
+ * same part. Adjacent (seam) modules and the closed-loop seam are
1842
+ * excluded. Recomputed on config / track-transform change (NOT per-frame,
1843
+ * since modules don't move at 60 Hz) — `onCollisionsChange` fires only
1844
+ * when the overlap set actually changes.
1845
+ */
1846
+ modules?: ModuleCollisionOptions;
1847
+ }
1848
+ interface MoverCollisionOptions {
1849
+ enabled?: boolean;
1850
+ /** Report a pair as colliding while their gap is below this. Default 0. */
1851
+ warningGapMm?: number;
1852
+ /** Throttle the check (e.g. 50 for 20 Hz). Default 0 = every frame. */
1853
+ intervalMs?: number;
1854
+ /** Treat the part as a closed loop regardless of chain geometry. */
1855
+ forceClosedLoop?: boolean;
1856
+ onCollisionsChange?: (collisions: MoverCollision[]) => void;
1857
+ }
1858
+ interface ModuleCollisionOptions {
1859
+ enabled?: boolean;
1860
+ /** Report a pair as overlapping while their gap is below this. Default 0. */
1861
+ warningGapMm?: number;
1862
+ onCollisionsChange?: (collisions: ModuleCollision[]) => void;
1863
+ }
1864
+
1865
+ /**
1866
+ * Viewer errors.
1867
+ *
1868
+ * `XtsViewerErrorException` is the one runtime value in the public type
1869
+ * surface — everything else the viewer exports as `types` is erased at
1870
+ * compile time. It is thrown by the normalization pass for configurations
1871
+ * that cannot be rendered at all; recoverable problems are reported through
1872
+ * the `onError` callback as plain `XtsViewerError` records instead.
1873
+ */
1874
+ type XtsViewerErrorCode = 'asset-load-failed' | 'unknown-module-type' | 'unknown-mover-type' | 'unknown-tool-type' | 'invalid-config' | 'unmatched-clothoid-half' | 'webgl-context-lost';
1875
+ interface XtsViewerError {
1876
+ code: XtsViewerErrorCode;
1877
+ message: string;
1878
+ details?: unknown;
1879
+ }
1880
+ declare class XtsViewerErrorException extends Error {
1881
+ readonly code: XtsViewerErrorCode;
1882
+ readonly details?: unknown;
1883
+ constructor(code: XtsViewerErrorCode, message: string, details?: unknown);
1884
+ }
1885
+
1886
+ /**
1887
+ * Asset manifest — per-type overrides for the GLB URLs the viewer loads.
1888
+ *
1889
+ * A host registers custom or relocated assets here instead of replacing the
1890
+ * whole `assetsBaseUrl`. Entries fall back to the default CDN mapping for any
1891
+ * type they do not name.
1892
+ */
1893
+
1894
+ interface AssetManifest {
1895
+ /** Override per moduleType. Falls back to default mapping otherwise. */
1896
+ modules?: Partial<Record<ModuleType3D, ModuleAssetEntry>>;
1897
+ movers?: Partial<Record<MoverType3D, MoverAssetEntry>>;
1898
+ tools?: Partial<Record<MoverToolType3D, MoverToolAssetEntry>>;
1899
+ magnetPlates?: Partial<Record<MagnetPlateType3D, MagnetPlateAssetEntry>>;
1900
+ guidingRails?: Partial<Record<RailType3D, RailAssetEntry>>;
1901
+ }
1902
+ interface RailAssetEntry {
1903
+ glbUrl: string;
1904
+ sidecarUrl?: string;
1905
+ }
1906
+ interface ModuleAssetEntry {
1907
+ /** Per-RailSystem GLB URL (relative to assetsBaseUrl). */
1908
+ glbByRailSystem: {
1909
+ Beckhoff: string | null;
1910
+ HepcoGfx?: string | null;
1718
1911
  };
1912
+ sidecarUrl?: string;
1719
1913
  }
1720
- interface BuiltChain {
1721
- modules: BuiltModule[];
1722
- /** Σ moduleLength_i. */
1723
- trackLengthMm: number;
1724
- /** End-of-chain pose; useful for closure-checks. */
1725
- endPose: PlanarPose;
1914
+ interface MoverAssetEntry {
1915
+ glbUrl: string;
1916
+ sidecarUrl?: string;
1917
+ }
1918
+ interface MoverToolAssetEntry {
1919
+ glbUrl: string;
1920
+ sidecarUrl?: string;
1921
+ }
1922
+ interface MagnetPlateAssetEntry {
1923
+ glbUrl: string;
1924
+ sidecarUrl?: string;
1925
+ }
1926
+
1927
+ /**
1928
+ * Public types for the measurement + annotation feature.
1929
+ *
1930
+ * Everything here is expressed in the viewer's WORLD frame — millimetres,
1931
+ * Z-up, identical to `XtsViewer3DRef.getMoverWorldTransform()` and
1932
+ * `CameraState.positionMm`. That is what makes one implementation serve both
1933
+ * the perspective 3D view and the orthographic 2D plan view
1934
+ * (`projection='orthographic'`): only the camera differs, the geometry does
1935
+ * not.
1936
+ */
1937
+
1938
+ /**
1939
+ * How a measurement point was derived. `'free'` is the raw ray hit (surface
1940
+ * or ground plane); everything else is a snap the picker resolved to.
1941
+ */
1942
+ type SnapKind = 'free' | 'vertex' | 'edge-midpoint' | 'face-center' | 'grid' | 'mover-center' | 'module-boundary' | 'module-center' | 'station-stop' | 'track-path';
1943
+ /** Scene entity a picked point belongs to, when the picker could resolve one. */
1944
+ type MeasuredEntityRef = {
1945
+ kind: 'module';
1946
+ ref: ModuleRef;
1947
+ } | {
1948
+ kind: 'mover';
1949
+ ref: MoverRef;
1950
+ } | {
1951
+ kind: 'station';
1952
+ stationId: number;
1953
+ };
1954
+ /** A position on a part's track path, in the XPU's user position frame. */
1955
+ interface TrackAnchor {
1956
+ processingUnitObjectId: number;
1957
+ /** `PartConfig.objectId`. */
1958
+ partObjectId: number;
1959
+ /** Position along the part, user frame (same convention as movers). */
1960
+ partPositionMm: number;
1961
+ }
1962
+ /** One measurement vertex. */
1963
+ interface MeasurementPoint {
1964
+ /** World position (mm). */
1965
+ positionMm: Vec3;
1966
+ /** How the point was obtained. Default `'free'` when omitted. */
1967
+ snap?: SnapKind;
1968
+ /** Entity the point sits on, when known. */
1969
+ entity?: MeasuredEntityRef;
1970
+ /**
1971
+ * Track position, set whenever the point resolved onto a part's path.
1972
+ * Required on both endpoints of a `'trackDistance'` measurement.
1973
+ */
1974
+ track?: TrackAnchor;
1975
+ }
1976
+ /** Snapping configuration. */
1977
+ interface SnapOptions {
1978
+ /** Master switch. Default `true`. */
1979
+ enabled?: boolean;
1980
+ /**
1981
+ * Snap kinds to consider, highest priority first. Default:
1982
+ * `['vertex', 'edge-midpoint', 'mover-center', 'module-boundary',
1983
+ * 'station-stop', 'track-path', 'face-center']`.
1984
+ */
1985
+ kinds?: SnapKind[];
1986
+ /** Screen-space radius (px) a candidate must fall within. Default `14`. */
1987
+ radiusPx?: number;
1988
+ /**
1989
+ * Grid step (mm) for the `'grid'` snap, applied to the free hit point when
1990
+ * no other candidate matched and `'grid'` is in `kinds`. Default `0` (off).
1991
+ */
1992
+ gridMm?: number;
1993
+ }
1994
+ type MeasurementId = string;
1995
+ type MeasurementKind = 'distance' | 'angle' | 'path' | 'trackDistance';
1996
+ /** Axis constraint for a distance measurement. */
1997
+ type MeasurementAxis = 'free' | 'x' | 'y' | 'z';
1998
+ interface MeasurementBase {
1999
+ id: MeasurementId;
2000
+ /** Optional caption rendered above the value. */
2001
+ label?: string;
2002
+ /** Overrides `MeasurementStyle.color` for this one measurement. */
2003
+ color?: string;
2004
+ /** Default `true`. */
2005
+ visible?: boolean;
2006
+ /** Free-form payload the host can round-trip (never read by the viewer). */
2007
+ meta?: Record<string, unknown>;
2008
+ }
2009
+ /** Straight-line distance between two points. */
2010
+ interface DistanceMeasurement extends MeasurementBase {
2011
+ kind: 'distance';
2012
+ points: [MeasurementPoint, MeasurementPoint];
2013
+ /** Constrain the reported value to one axis. Default `'free'`. */
2014
+ axis?: MeasurementAxis;
2015
+ /** Render dashed ΔX / ΔY / ΔZ guides. Default `false`. */
2016
+ showComponents?: boolean;
2017
+ }
2018
+ /** Angle at `points[1]` between the legs to `points[0]` and `points[2]`. */
2019
+ interface AngleMeasurement extends MeasurementBase {
2020
+ kind: 'angle';
2021
+ /** `[legA, vertex, legB]` — the middle point is the vertex. */
2022
+ points: [MeasurementPoint, MeasurementPoint, MeasurementPoint];
2023
+ }
2024
+ /** Polyline through N points; reports per-segment and total length. */
2025
+ interface PathMeasurement extends MeasurementBase {
2026
+ kind: 'path';
2027
+ points: MeasurementPoint[];
2028
+ /** Close the polyline back to the first point. Default `false`. */
2029
+ closed?: boolean;
2030
+ /** Label every segment, not just the total. Default `true`. */
2031
+ showSegmentLengths?: boolean;
2032
+ }
2033
+ /**
2034
+ * Distance ALONG the XTS track between two points on the same part — the
2035
+ * distance a mover actually travels, not the straight line.
2036
+ */
2037
+ interface TrackDistanceMeasurement extends MeasurementBase {
2038
+ kind: 'trackDistance';
2039
+ points: [MeasurementPoint, MeasurementPoint];
2040
+ }
2041
+ type Measurement = DistanceMeasurement | AngleMeasurement | PathMeasurement | TrackDistanceMeasurement;
2042
+ /** Same as `Measurement`, but the store generates the `id` when omitted. */
2043
+ type MeasurementInput = (Omit<DistanceMeasurement, 'id'> & {
2044
+ id?: MeasurementId;
2045
+ }) | (Omit<AngleMeasurement, 'id'> & {
2046
+ id?: MeasurementId;
2047
+ }) | (Omit<PathMeasurement, 'id'> & {
2048
+ id?: MeasurementId;
2049
+ }) | (Omit<TrackDistanceMeasurement, 'id'> & {
2050
+ id?: MeasurementId;
2051
+ });
2052
+ /** Computed value of a measurement. */
2053
+ interface MeasurementResult {
2054
+ id: MeasurementId;
2055
+ kind: MeasurementKind;
2056
+ /** Millimetres for lengths, degrees for angles. */
2057
+ value: number;
2058
+ unit: 'mm' | 'deg';
2059
+ /** Formatted value as rendered in the scene (e.g. `'1 234.5 mm'`). */
2060
+ text: string;
2061
+ /** Signed component deltas — `'distance'` only. */
2062
+ deltaMm?: Vec3;
2063
+ /** Per-segment lengths — `'path'` only. */
2064
+ segmentLengthsMm?: number[];
2065
+ /**
2066
+ * `true` when the measurement could not be evaluated (e.g. a
2067
+ * `trackDistance` whose endpoints sit on different parts). `value` is then
2068
+ * the straight-line fallback.
2069
+ */
2070
+ incomplete?: boolean;
2071
+ }
2072
+ /** Where an annotation hangs. Mover anchors follow the live mover position. */
2073
+ type AnnotationAnchor = {
2074
+ kind: 'world';
2075
+ positionMm: Vec3;
2076
+ } | {
2077
+ kind: 'mover';
2078
+ ref: MoverRef;
2079
+ } | {
2080
+ kind: 'module';
2081
+ ref: ModuleRef;
2082
+ } | ({
2083
+ kind: 'trackPosition';
2084
+ } & TrackAnchor);
2085
+ interface Annotation {
2086
+ id: string;
2087
+ text: string;
2088
+ anchor: AnnotationAnchor;
2089
+ /**
2090
+ * Leader-line offset from the anchor to the label (mm, world frame).
2091
+ * Default `[0, 0, 250]`.
2092
+ */
2093
+ offsetMm?: Vec3;
2094
+ color?: string;
2095
+ /** Text height (mm). Default `MeasurementStyle.labelSizeMm`. */
2096
+ sizeMm?: number;
2097
+ /** Marker drawn at the anchor. Default `'Sphere'`. */
2098
+ markerShape?: MarkerShape;
2099
+ markerSizeMm?: number;
2100
+ /** Default `true`. */
2101
+ visible?: boolean;
2102
+ meta?: Record<string, unknown>;
2103
+ }
2104
+ type AnnotationInput = Omit<Annotation, 'id'> & {
2105
+ id?: string;
2106
+ };
2107
+ type MeasurementTool = 'none' | 'distance' | 'angle' | 'path' | 'trackDistance' | 'annotation';
2108
+ /** In-progress pick. `null` while no tool is armed. */
2109
+ interface MeasurementDraft {
2110
+ tool: Exclude<MeasurementTool, 'none'>;
2111
+ /** Points committed so far. */
2112
+ points: MeasurementPoint[];
2113
+ /** Point currently under the cursor, or `null` when off the scene. */
2114
+ hover: MeasurementPoint | null;
2115
+ }
2116
+ interface MeasurementStyle {
2117
+ /** Line + label colour of committed measurements. Default `'#00E5FF'`. */
2118
+ color?: string;
2119
+ /** Colour of the in-progress draft. Default `'#FFC400'`. */
2120
+ draftColor?: string;
2121
+ /** Annotation colour. Default `'#FFFFFF'`. */
2122
+ annotationColor?: string;
2123
+ /** Line width in pixels. Default `2`. */
2124
+ lineWidthPx?: number;
2125
+ /** Text height (mm). Default `40`. */
2126
+ labelSizeMm?: number;
2127
+ /** Endpoint marker diameter (mm). Default `18`. */
2128
+ pointSizeMm?: number;
2129
+ /** Decimal places on values. Default `1`. */
2130
+ decimals?: number;
2131
+ /** Unit suffix appended to lengths. Default `' mm'`. */
2132
+ unitSuffix?: string;
2133
+ /** Font URL passed to troika (see `DEFAULT_LABEL_FONT_URL`). */
2134
+ fontFamily?: string;
2135
+ /**
2136
+ * Draw measurement lines and markers through solid geometry
2137
+ * (`depthTest: false`). Default `true` — CAD viewers keep dimensions
2138
+ * readable regardless of what is in front of them.
2139
+ */
2140
+ alwaysOnTop?: boolean;
2141
+ }
2142
+ /** Serializable snapshot of everything the measurement feature holds. */
2143
+ interface MeasurementDocument {
2144
+ schemaVersion: 1;
2145
+ measurements: Measurement[];
2146
+ annotations: Annotation[];
1726
2147
  }
1727
2148
  /**
1728
- * Build a chain from a part's module list.
1729
- *
1730
- * Throws XtsViewerErrorException with code `unknown-module-type` on lookup
1731
- * miss.
1732
- */
1733
- declare function buildChain(modules: ReadonlyArray<ModuleEntry>): BuiltChain;
1734
- /**
1735
- * Locate the module containing `partPositionMm` along the chain.
1736
- * First module with partPosition < startInPart + trackLength; at boundary
1737
- * `partPosition == trackLength` the LAST module is returned with
1738
- * `localPos = trackLength`.
2149
+ * `measurement` prop of `<XtsViewer3D>`. Data can be driven either
2150
+ * declaratively (`measurements` / `annotations` — controlled arrays) or
2151
+ * imperatively via `viewerRef.current.measurements`. When the controlled
2152
+ * arrays are supplied they win on every render, so a host that uses them must
2153
+ * persist what the change callbacks report.
1739
2154
  */
1740
- declare function findModuleAt(chain: BuiltChain, partPositionMm: number): {
1741
- module: BuiltModule;
1742
- localPosMm: number;
1743
- } | null;
2155
+ interface MeasurementOptions {
2156
+ /** Armed tool. Default `'none'` — no picking, no cursor change. */
2157
+ tool?: MeasurementTool;
2158
+ /** Controlled measurement list. Omit to let the viewer keep its own. */
2159
+ measurements?: MeasurementInput[];
2160
+ /** Controlled annotation list. Omit to let the viewer keep its own. */
2161
+ annotations?: AnnotationInput[];
2162
+ snap?: SnapOptions;
2163
+ style?: MeasurementStyle;
2164
+ /**
2165
+ * Z of the fallback pick plane (mm) used when the ray misses all geometry,
2166
+ * so free space can be measured. Default `0`; `null` disables the fallback.
2167
+ */
2168
+ groundPlaneZMm?: number | null;
2169
+ /** Fired after every change to the measurement list. */
2170
+ onMeasurementsChange?: (measurements: Measurement[], results: MeasurementResult[]) => void;
2171
+ /** Fired once when a tool completes a measurement. */
2172
+ onMeasurementCreate?: (measurement: Measurement, result: MeasurementResult) => void;
2173
+ onAnnotationsChange?: (annotations: Annotation[]) => void;
2174
+ onAnnotationCreate?: (annotation: Annotation) => void;
2175
+ /** Fired on every draft change (point added, hover moved, cancelled). */
2176
+ onDraftChange?: (draft: MeasurementDraft | null) => void;
2177
+ /**
2178
+ * Text for an annotation the `'annotation'` tool just placed. Return
2179
+ * `null` to abort. Default: `'Annotation'`.
2180
+ */
2181
+ annotationTextFactory?: (point: MeasurementPoint) => string | null;
2182
+ }
1744
2183
  /**
1745
- * Mover world pose at `partPositionMm`.
2184
+ * A partial measurement, kind by kind.
1746
2185
  *
1747
- * The module's startWorldPose is composed with the local path sample at
1748
- * `localPosMm` along that module's path.
2186
+ * `Omit` does not distribute over a union: `Omit<Measurement, 'id' | 'kind'>`
2187
+ * keeps only the properties every measurement kind has, which dropped `axis`
2188
+ * and `showComponents` — fields `MeasurementOverlay` actually renders. Mapping
2189
+ * over the union first keeps them.
1749
2190
  */
1750
- declare function moverWorldAt(chain: BuiltChain, partPositionMm: number): PlanarPose | null;
1751
- /** Convenience: chain from full ModuleEntry[] returning trackLength alone. */
1752
- declare function trackLengthOf(modules: ReadonlyArray<ModuleEntry>): number;
2191
+ type MeasurementPatch = Partial<Measurement extends infer M ? M extends Measurement ? Omit<M, 'id' | 'kind'> : never : never>;
1753
2192
 
1754
2193
  /**
1755
- * normalizeXtsConfig — pre-render pass.
1756
- *
1757
- * Fail-fast validation + AT2050 half-clothoid merging:
1758
- * AT2050_0500 + AT2050_0501 (gap=0) → AT2050_0500_180
1759
- * ATH2050_0500 + ATH2050_0501 (gap=0) → ATH2050_0500_180
1760
- *
1761
- * Errors thrown via XtsViewerErrorException:
1762
- * - 'unknown-module-type'
1763
- * - 'unknown-mover-type'
1764
- * - 'unmatched-clothoid-half' (0500 without 0501 or vice versa)
2194
+ * `XtsModelDocument` — the canonical serialized form of a viewer scene.
1765
2195
  *
1766
- * Warnings (collected, non-fatal) returned alongside the normalized config:
1767
- * - mover.partOid not in part-list
1768
- * - duplicate mover.id within an XPU
1769
- * - mover.partPositionMm out of range (clamped)
2196
+ * What `viewerRef.current.exportModel()` produces and what a host persists:
2197
+ * the configuration, optionally the runtime mover state, and optionally the
2198
+ * measurements. The measurement half keeps its own types next to its
2199
+ * implementation (`measurement/types.ts`); only the serialized document
2200
+ * surfaces here.
1770
2201
  */
1771
2202
 
1772
- interface NormalizedXtsConfig extends XtsConfig {
2203
+ interface XtsModelDocument {
2204
+ schemaVersion: 1;
2205
+ meta: {
2206
+ generatedAt: string;
2207
+ generator: string;
2208
+ sourceFormat?: string;
2209
+ sourceFileHash?: string;
2210
+ };
2211
+ config: XtsConfig;
2212
+ runtime?: XtsRuntimeState;
2213
+ railSystem?: RailSystem | {
2214
+ byProcessingUnit: Record<number, RailSystem>;
2215
+ };
1773
2216
  /**
1774
- * Same shape as the input, with all parts' modules pre-merged + validated.
1775
- * Mover positions are clamped, but the original values are preserved in
1776
- * `__warnings` for diagnostics.
2217
+ * Measurements + annotations placed on the layout, as returned by
2218
+ * `XtsViewer3DRef.measurements.toJSON()`. Omitted when there are none, so
2219
+ * documents written before the measurement feature stay byte-identical.
1777
2220
  */
1778
- readonly __warnings: NormalizationWarning[];
1779
- }
1780
- interface NormalizationWarning {
1781
- code: 'mover-out-of-range' | 'mover-orphan' | 'mover-duplicate-id' | 'mover-missing-id' | 'mover-invalid-id' | 'empty-part' | 'prepared-module' | 'duplicate-xpu-object-id' | 'missing-xpu-object-id' | 'invalid-xpu-object-id' | 'duplicate-part-object-id' | 'missing-part-object-id' | 'invalid-part-object-id' | 'duplicate-part-global-number' | 'missing-part-global-number' | 'invalid-part-global-number' | 'duplicate-module-global-number' | 'missing-module-global-number' | 'invalid-module-global-number';
1782
- message: string;
1783
- details?: unknown;
2221
+ measurements?: MeasurementDocument;
1784
2222
  }
1785
- /**
1786
- * Normalizer handle.
1787
- *
1788
- * This used to carry a single-slot structural cache keyed on a
1789
- * `JSON.stringify` of the config with the frame-paced fields stripped out.
1790
- * It was removed: the only production caller is `useNormalizedConfig`, whose
1791
- * `useMemo` already collapses repeat calls on an unchanged config, so the
1792
- * cache could fire only when the config identity changed but its structure
1793
- * did not — and in exactly that case the "fast path" cost a full stringify
1794
- * of the config plus a full rebuild of every XPU, which is strictly more
1795
- * work than the single O(n) pass it was avoiding.
1796
- *
1797
- * It also cost correctness twice over. The key stripped `status` by name at
1798
- * every depth, so it removed `ModuleEntry.status` along with the mover field
1799
- * it was aimed at, while the clone path rebuilt only movers — freezing
1800
- * declarative module warnings after the first normalize. And it forced a
1801
- * second copy of the mover clamp that had already drifted from `clampMover`
1802
- * (different `details` key, different `original` value), so hosts parsing
1803
- * warning details saw two shapes for one warning code.
1804
- *
1805
- * The class stays because it is part of the published API. It is now a thin
1806
- * handle over the pure function.
1807
- */
1808
- declare class XtsConfigNormalizer {
1809
- normalize(config: XtsConfig): NormalizedXtsConfig;
1810
- /** No-op. Kept so existing callers keep compiling; there is no cache. */
1811
- clearCache(): void;
2223
+ interface XtsRuntimeState {
2224
+ movers: Array<{
2225
+ processingUnitObjectId: number;
2226
+ moverIndex: number;
2227
+ id: number;
2228
+ partOid: number;
2229
+ partPositionMm: number;
2230
+ velocity?: {
2231
+ mmPerSec: number;
2232
+ };
2233
+ status?: {
2234
+ warning?: boolean;
2235
+ error?: boolean;
2236
+ };
2237
+ }>;
2238
+ selection?: SelectionState;
2239
+ camera?: CameraState;
2240
+ display?: Partial<DisplayOptions>;
1812
2241
  }
1813
- declare function normalizeXtsConfig(config: XtsConfig): NormalizedXtsConfig;
1814
2242
 
1815
2243
  /**
1816
- * Mover collision detection.
1817
- *
1818
- * Sub-millimetre accurate for the dominant XTS case: two movers travelling
1819
- * along the same chain. Each mover occupies a 1D footprint of
1820
- * `pathLengthMm` centred on `partPositionMm`, so the collision predicate
1821
- * collapses to one subtraction in path-space:
1822
- *
1823
- * centerDistance = arc-length |pos_a − pos_b|
1824
- * requiredGap = (pathLength_a + pathLength_b) / 2
1825
- * penetration = requiredGap − centerDistance
1826
- * collision iff penetration > 0 (≥ 0 for "touching")
1827
- *
1828
- * For closed-loop chains (oval tracks, U-turn pairs) the arc-length is
1829
- * taken as the *minimum* of the forward and the wrap-around path so two
1830
- * movers near the seam (e.g. 50 mm and trackLength − 50 mm) are treated
1831
- * as physically adjacent. Open chains are detected by comparing the
1832
- * chain's start- and end-pose; for those the wrap-around distance is
1833
- * simply not considered.
2244
+ * SidecarLoader — fetches per-asset JSON sidecars.
1834
2245
  *
1835
- * Floating-point precision: positions are kept in mm in float64. A typical
1836
- * XTS layout has trackLength ≤ 20 m → < 2×10⁴ mm, well inside the
1837
- * 2⁵³-mantissa range, so subtractive cancellation costs at most ~10⁻¹²
1838
- * mm — orders of magnitude below sub-millimetre.
2246
+ * Sidecars carry origin-correction (translateMm + rotationDegEuler) plus
2247
+ * path metadata. They live next to the GLB at <baseUrl>/<id>.meta.json (or
2248
+ * <id>.tool.meta.json for mover tools).
1839
2249
  *
1840
- * Multi-XPU (different chains): not handled here. Cross-track collisions
1841
- * are dominated by track-transform layout and would need an OBB / mesh
1842
- * test in 3D space; that's a different concern from "two movers on the
1843
- * same line about to crash".
2250
+ * On 404 the loader falls back to default origin-correction `(0,0,0)` /
2251
+ * `(0,0,0)`. The renderer can still place the GLB; the calibration tool is
2252
+ * the authoritative source for sidecar values.
1844
2253
  */
1845
2254
 
2255
+ interface OriginCorrection {
2256
+ translateMm: Vec3;
2257
+ rotationDegEuler: Vec3;
2258
+ }
2259
+ interface ModuleSidecar {
2260
+ moduleType: ModuleType3D;
2261
+ glbByRailSystem: {
2262
+ Beckhoff: string | null;
2263
+ HepcoGfx?: string | null;
2264
+ };
2265
+ originCorrection: OriginCorrection;
2266
+ pathType: 'Straight' | 'Curve' | 'Free';
2267
+ moduleLengthMm: number;
2268
+ endAngleDeg: number;
2269
+ freePathFile?: string;
2270
+ approximateBoundsMm?: {
2271
+ min: Vec3;
2272
+ max: Vec3;
2273
+ };
2274
+ }
2275
+ interface MoverSidecar {
2276
+ moverType: MoverType3D;
2277
+ glb: string;
2278
+ originCorrection: OriginCorrection;
2279
+ magnetPlateCenterMm: Vec3;
2280
+ pathLengthMm: number;
2281
+ }
1846
2282
  /**
1847
- * One detected pair. Result list is symmetric (a/b is reported once per
1848
- * pair, with `a` carrying the lower index pair-canonical).
2283
+ * Calibration sidecar for a standalone magnet plate set. The plate is drawn
2284
+ * in the mover-local frame (+X travel, +Z up) with this correction applied,
2285
+ * so `originCorrection` is what seats it on the carriage.
1849
2286
  */
1850
- interface MoverCollision {
1851
- a: MoverRef;
1852
- b: MoverRef;
1853
- /**
1854
- * Stable IDs of the two movers (mirrors `MoverConfig.id`). Useful when
1855
- * the consumer keys by id rather than by `moverIndex`.
1856
- */
1857
- idA: number;
1858
- idB: number;
1859
- /**
1860
- * Penetration along the 1D path:
1861
- * • `> 0` — movers overlap by this many mm.
1862
- * • `= 0` — touching exactly.
1863
- * • `< 0` — closest-approach gap (only present in result when
1864
- * `warningGapMm` is set; the magnitude is the free-space
1865
- * distance between the closer footprint edges).
1866
- */
1867
- penetrationMm: number;
1868
- /**
1869
- * Both `partPositionMm` values at the time of the test. Lets the
1870
- * consumer recompute or visualise without re-querying.
1871
- */
1872
- positionAMm: number;
1873
- positionBMm: number;
1874
- /** Mover lengths (catalog `pathLengthMm`). */
1875
- pathLengthAMm: number;
1876
- pathLengthBMm: number;
1877
- /** True when measurement wrapped around the chain end (closed-loop seam). */
1878
- viaWraparound: boolean;
2287
+ interface MagnetPlateSidecar {
2288
+ plateType: MagnetPlateType3D;
2289
+ glb: string;
2290
+ originCorrection: OriginCorrection;
2291
+ poleCount: number;
2292
+ lengthMm: number;
2293
+ }
2294
+ interface MoverToolSidecar {
2295
+ toolType: MoverToolType3D;
2296
+ glbUrl: string;
2297
+ originCorrection: OriginCorrection;
2298
+ defaultOffsetMm: Vec3;
2299
+ approximateBoundsMm?: {
2300
+ min: Vec3;
2301
+ max: Vec3;
2302
+ };
2303
+ label: string;
1879
2304
  }
1880
- interface CheckMoverCollisionsOptions {
1881
- /**
1882
- * When `> 0`, near-misses with closest-approach gap up to this many mm
1883
- * are also returned (with `penetrationMm < 0`). Default 0 — only actual
1884
- * collisions / contacts.
1885
- */
1886
- warningGapMm?: number;
1887
- /**
1888
- * Force-treat the chain as closed (consider wrap-around distance) or
1889
- * open (don't). When omitted, auto-detected by comparing
1890
- * `chain.endPose` to `startPose ≈ identity` within ~0.1 mm tolerance.
1891
- */
1892
- forceClosedLoop?: boolean;
2305
+ /**
2306
+ * Calibration sidecar for a guiding-rail GLB. The rail is rendered in the
2307
+ * same module-local frame as the module (i.e. under the module's
2308
+ * `startWorldPose`) with this correction applied to the GLB scene.
2309
+ */
2310
+ interface RailSidecar {
2311
+ railType: RailType3D;
2312
+ glb: string;
2313
+ originCorrection: OriginCorrection;
2314
+ approximateBoundsMm?: {
2315
+ min: Vec3;
2316
+ max: Vec3;
2317
+ };
1893
2318
  }
2319
+ declare const DEFAULT_ORIGIN_CORRECTION: OriginCorrection;
1894
2320
  /**
1895
- * Effective mover state passed into the predicate. Decoupled from
1896
- * MoverConfig so consumers can plug in live positions from the
1897
- * MoverPositionStore (60-Hz tick) without round-tripping through React
1898
- * state.
2321
+ * Generic sidecar fetch with promise caching + graceful fallback. Returns
2322
+ * `undefined` on 404 (caller substitutes a default), and surfaces network or
2323
+ * parse errors as `Error` (caller is expected to forward to onError).
1899
2324
  */
1900
- interface MoverProbe {
1901
- ref: MoverRef;
1902
- id: number;
1903
- partOid: number;
1904
- moverType: MoverType3D;
1905
- partPositionMm: number;
2325
+ declare class SidecarLoader {
2326
+ private readonly timeoutMs;
2327
+ private readonly cache;
2328
+ constructor(timeoutMs?: number);
2329
+ clear(): void;
1906
2330
  /**
1907
- * Override the catalog `pathLengthMm`. Use for `Custom` movers where
1908
- * the layout's `pathLengthMm` is the source of truth.
2331
+ * Fetch and cache the sidecar at `url`. The loader validates the one field
2332
+ * every sidecar shape shares (`originCorrection`) and hands the rest to the
2333
+ * caller as `T` — the kind is a property of the URL, not of the loader.
1909
2334
  */
1910
- pathLengthMm?: number;
2335
+ fetch<T>(url: string): Promise<T | undefined>;
1911
2336
  }
1912
2337
  /**
1913
- * Pair-wise 1D collision test for movers on the same chain.
1914
- *
1915
- * `chain` is the BuiltChain corresponding to the partOid all probed
1916
- * movers travel on. `probes` may contain movers from *any* part — this
1917
- * function only considers pairs where `partOid` matches the chain.
1918
- *
1919
- * Returns one entry per colliding (or near-miss, when `warningGapMm > 0`)
1920
- * pair, sorted by `penetrationMm` descending so the deepest collisions
1921
- * appear first.
2338
+ * Resolve the effective origin correction for a module sidecar — falls back
2339
+ * to identity when the sidecar is absent.
1922
2340
  */
1923
- declare function checkSamePathCollisions(chain: BuiltChain, partOid: number, probes: ReadonlyArray<MoverProbe>, opts?: CheckMoverCollisionsOptions): MoverCollision[];
2341
+ declare function resolveOriginCorrection(sidecar: {
2342
+ originCorrection?: OriginCorrection;
2343
+ } | undefined): OriginCorrection;
2344
+ /** Standard sidecar filename from a base id. */
2345
+ declare function moduleSidecarFilename(moduleType: ModuleType3D): string;
2346
+ declare function moverSidecarFilename(moverType: MoverType3D): string;
2347
+ declare function toolSidecarFilename(toolType: MoverToolType3D): string;
2348
+ declare function railSidecarFilename(railType: RailType3D): string;
2349
+ /** Anything that names a GLB per rail system — a sidecar or a manifest entry. */
2350
+ interface GlbByRailSystem {
2351
+ glbByRailSystem: {
2352
+ Beckhoff: string | null;
2353
+ HepcoGfx?: string | null;
2354
+ };
2355
+ }
1924
2356
  /**
1925
- * Collision check across a whole config: build a probe per mover, group by
1926
- * part, and run `checkSamePathCollisions` on each group with two or more.
1927
- *
1928
- * `livePositionOf` supplies the current position of a mover when one is
1929
- * being driven imperatively — the `MoverPositionStore` at 60 Hz — falling
1930
- * back to the config value. Passing it as a function rather than the store
1931
- * itself is what keeps this a pure geometry function: it was previously
1932
- * trapped inside the `useImperativeHandle` closure of `useXtsViewerHandle`,
1933
- * where it could only be exercised through a mounted canvas.
2357
+ * Pure helper: pick the GLB filename with rail-system fallback. Returns null
2358
+ * when the entry marks the module 'prepared but not delivered'.
1934
2359
  *
1935
- * Results are sorted so the deepest collision in the scene comes first.
2360
+ * Takes the structural shape rather than `ModuleSidecar` so
2361
+ * `resolveModuleGlbUrl`, which used to carry a line-for-line identical copy
2362
+ * of this body over manifest entries, can delegate here. Both are public
2363
+ * API, so both stay — but there is one implementation of the fallback rule.
1936
2364
  */
1937
- declare function checkAllMoverCollisions(config: NormalizedXtsConfig, livePositionOf: (moverId: number) => number | undefined, opts?: CheckMoverCollisionsOptions): MoverCollision[];
2365
+ declare function pickGlbForRail(entry: GlbByRailSystem | undefined, rail: RailSystem): string | null;
1938
2366
 
1939
2367
  /**
1940
- * Module collision / overlap detection.
1941
- *
1942
- * The mover collision (moverCollision.ts) is a 1D arc-length test for two
1943
- * movers on the *same* chain; its header explicitly declines cross-XPU /
1944
- * cross-track cases, which "would need an OBB / mesh test in 3D space".
1945
- * This file is exactly that test, for the physical track *modules*.
1946
- *
1947
- * Each module is modelled as a chain of oriented bounding boxes (OBBs) that
1948
- * follow its real centerline: the path is sampled (sampleModulePath) and each
1949
- * segment becomes a small box (±halfWidth in Y, 0..height in Z) oriented along
1950
- * the local tangent, then transformed into world space by
1951
- *
1952
- * world = orientation ⊗ trackTransform ⊗ partTransformation ⊗ chainPose
1953
- *
1954
- * Sampling the path (rather than a single straight box per module) is what
1955
- * keeps curves and U-turns faithful — a single straight box would poke
1956
- * outside the arc and produce both false positives and false negatives on a
1957
- * closed oval. Straights collapse to one box; curves / free paths use several.
1958
- *
1959
- * Pairs are tested with the Separating Axis Theorem (15 axes) over their
1960
- * segment boxes, yielding a penetration depth (minimum translation distance)
1961
- * as `overlapMm`:
1962
- *
1963
- * overlapMm > 0 — boxes overlap by this many mm (deepest sub-box pair).
1964
- * overlapMm = 0 — touching exactly.
1965
- * overlapMm < 0 — closest-approach gap (only reported when
1966
- * `warningGapMm` is set).
1967
- *
1968
- * Adjacent modules in the same part butt up edge-to-edge (ChainBuilder), so
1969
- * consecutive `(i, i+1)` pairs — and the `(N-1, 0)` seam of a closed loop —
1970
- * are excluded; everything else (cross-part, cross-XPU, same-part
1971
- * non-adjacent) is tested.
2368
+ * Option shapes shared between the public `<XtsViewer3D>` props and the
2369
+ * internal `<XtsScene>` / `<ProcessingUnitRoot>` props.
1972
2370
  *
1973
- * The box cross-section (`halfWidthMm` 50, `heightMm` 100) is a coarse but
1974
- * deterministic, asset-independent envelope. Real per-module CAD bounds live
1975
- * in the GLB sidecars (`approximateBoundsMm`) and could refine it later.
1976
- * Likewise the `warningGapMm` near-miss is an axis-projected SAT separation,
1977
- * not a true Euclidean (GJK) distance — slightly conservative for
1978
- * corner-to-corner cases.
2371
+ * Each of these used to be declared twice — once on each side of that
2372
+ * boundary, character for character — so the two could drift silently, and
2373
+ * `SidecarOverrides` was even exported under two public names. Declared once
2374
+ * here and imported by both.
1979
2375
  */
1980
2376
 
1981
- /** Default box half-width (Y) — matches `approximateModuleBounds`. */
1982
- declare const DEFAULT_MODULE_HALF_WIDTH_MM = 50;
1983
- /** Default box height (Z) — matches `approximateModuleBounds`. */
1984
- declare const DEFAULT_MODULE_HEIGHT_MM = 100;
2377
+ /** Where the orientation gizmo sits in the canvas. */
2378
+ type ViewCubeAlignment = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center' | 'center-left' | 'center-right' | 'center-center';
1985
2379
  /**
1986
- * One detected pair. Symmetric: `a`/`b` are canonicalised so the lower
1987
- * `(processingUnitObjectId, partObjectId, moduleIndex)` tuple is always `a`.
2380
+ * The corner navigation gizmo: three labelled X / Y / Z axis handles that
2381
+ * track the camera and snap it onto an axis when clicked.
2382
+ *
2383
+ * Named `ViewCube*` for backwards compatibility — earlier versions drew a
2384
+ * navigation cube in the same spot.
1988
2385
  */
1989
- interface ModuleCollision {
1990
- a: ModuleRef;
1991
- b: ModuleRef;
1992
- moduleTypeA: ModuleType3D;
1993
- moduleTypeB: ModuleType3D;
1994
- /** Forwarded `ModuleEntry.globalNumber` (status-store key), if present. */
1995
- globalNumberA?: number;
1996
- globalNumberB?: number;
2386
+ interface ViewCubeOptions {
2387
+ enabled?: boolean;
2388
+ /** Default `'bottom-right'`. */
2389
+ alignment?: ViewCubeAlignment;
2390
+ /** Distance from the aligned corner, in CSS px. Default `[80, 80]`. */
2391
+ marginPx?: [number, number];
2392
+ }
2393
+ /**
2394
+ * Keyboard navigation — moving the camera *through* the scene rather than
2395
+ * orbiting it around a fixed point.
2396
+ *
2397
+ * The arrow keys (and the WASD block) walk the camera and the orbit pivot
2398
+ * together along the ground plane, PageUp / PageDown climb, Shift goes
2399
+ * faster and Alt finer. Keys are read from the canvas once it has focus,
2400
+ * which it takes on pointer-down, so an embedded viewer never swallows the
2401
+ * host page's arrow keys.
2402
+ *
2403
+ * Off automatically while `lock.pan` is set — moving through the scene is
2404
+ * panning by another input device.
2405
+ */
2406
+ interface KeyboardNavigationOptions {
2407
+ /** Default `true`. */
2408
+ enabled?: boolean;
1997
2409
  /**
1998
- * Penetration depth along the minimum-translation axis of the deepest
1999
- * sub-box pair:
2000
- * • `> 0` — overlap (mm).
2001
- * • `= 0` — touching.
2002
- * • `< 0` — closest-approach gap (only when `warningGapMm` is set).
2410
+ * Multiplier on the default travel rate, which is ~0.6 × the current
2411
+ * orbit distance per second. Default `1`.
2003
2412
  */
2004
- overlapMm: number;
2005
- /** Centre-to-centre distance of the deepest sub-box pair (mm). */
2006
- centerDistanceMm: number;
2007
- /** Unit world-space axis of least penetration (the MTV direction). */
2008
- axis: Vec3;
2413
+ speed?: number;
2009
2414
  }
2010
- interface CheckModuleCollisionsOptions {
2415
+ /** Interaction locks. Each `true` disables that interaction. */
2416
+ interface InteractionLocks {
2417
+ rotate?: boolean;
2418
+ pan?: boolean;
2419
+ zoom?: boolean;
2420
+ selection?: boolean;
2421
+ }
2422
+ interface PerformanceOptions {
2011
2423
  /**
2012
- * When `> 0`, near-misses with an axis-projected gap up to this many mm
2013
- * are also returned (with `overlapMm < 0`). Default 0 — only actual
2014
- * overlaps / contacts.
2424
+ * Draw a processing unit's movers as one `InstancedMesh` instead of one
2425
+ * scene node each, cutting mover draw calls from N to 1. Default: false.
2426
+ *
2427
+ * Applies from four movers up, and only to movers with nothing bound to
2428
+ * them: a mover carrying tools, a mover-bound custom asset, or a visible
2429
+ * drive status still gets its own node. Turning on the anchor marker or
2430
+ * mover-id labels gives every mover a child object, so the batch is
2431
+ * skipped for that unit entirely. Selection and clicks work either way.
2015
2432
  */
2016
- warningGapMm?: number;
2433
+ instancing?: boolean;
2434
+ /** Stop the frameloop while the tab is hidden. Default: true. */
2435
+ autoPauseOnHidden?: boolean;
2017
2436
  /**
2018
- * Also test non-adjacent module pairs within the SAME part. Default true.
2019
- * Consecutive `(i, i+1)` pairs and the closed-loop `(N-1, 0)` seam are
2020
- * ALWAYS excluded regardless of this flag.
2437
+ * Enable demand rendering: frames only render when something changes
2438
+ * (camera move, mover position update, selection). Saves >90% GPU time
2439
+ * when the scene is idle. Default: true.
2021
2440
  */
2022
- includeSamePart?: boolean;
2023
- /** Box half-width in Y (mm). Default 50. */
2024
- halfWidthMm?: number;
2025
- /** Box height in Z (mm). Default 100. */
2026
- heightMm?: number;
2027
- }
2028
- /** Oriented bounding box in world space (unit axes + scaled half-extents). */
2029
- interface Obb {
2030
- center: Vector3;
2031
- axes: [Vector3, Vector3, Vector3];
2032
- halfExtents: [number, number, number];
2033
- /** Bounding-sphere radius (broad-phase reject). */
2034
- radius: number;
2035
- }
2036
- /** Pre-built per-module probe consumed by `checkModuleCollisions`. */
2037
- interface ModuleProbe {
2038
- ref: ModuleRef;
2039
- moduleType: ModuleType3D;
2040
- globalNumber?: number;
2041
- /** Adjacency grouping key (part.globalNumber — system-wide unique). */
2042
- partKey: number;
2043
- /** Index of this module within its part's chain. */
2044
- moduleIndex: number;
2045
- /** Segment boxes following the module centerline (≥ 1). */
2046
- obbs: Obb[];
2047
- /** Module-level bounding sphere enclosing all segment boxes. */
2048
- sphereCenter: Vector3;
2049
- sphereRadius: number;
2050
- }
2051
- /** A part fed to `buildModuleProbes`. */
2052
- interface ModuleCollisionPartInput {
2053
- /** ModuleRef.partObjectId (`PartConfig.objectId`). */
2054
- partObjectId: number;
2055
- /** Adjacency key (`PartConfig.globalNumber`). */
2056
- partKey: number;
2057
- partTransformation?: PartTransformation | null;
2058
- chain: BuiltChain;
2059
- }
2060
- /** An XPU fed to `buildModuleProbes`. */
2061
- interface ModuleCollisionXpuInput {
2062
- xpuObjectId: number;
2063
- /** Effective (override-resolved) per-XPU track transform. */
2064
- trackTransform?: TrackTransform | null;
2065
- parts: ReadonlyArray<ModuleCollisionPartInput>;
2441
+ demandRendering?: boolean;
2442
+ /**
2443
+ * Cap the device pixel ratio. Values above 2 rarely improve perceptible
2444
+ * quality but double fill-rate cost on Retina/4K displays. Default: 2.
2445
+ */
2446
+ maxDpr?: number;
2447
+ /**
2448
+ * Disable automatic per-frame shadow map updates. Shadows only re-render
2449
+ * on camera change. Default: true (when shadows enabled).
2450
+ */
2451
+ shadowOnDemand?: boolean;
2452
+ /**
2453
+ * Override the KTX2 (Basis) transcoder path used to decode compressed
2454
+ * textures in the GLB assets. Defaults to a CDN copy pinned to the bundled
2455
+ * three.js revision. Point this at a self-hosted
2456
+ * `basis_transcoder.{js,wasm}` directory for offline / air-gapped use.
2457
+ */
2458
+ ktx2TranscoderUrl?: string;
2066
2459
  }
2067
2460
  /**
2068
- * Turn a list of XPUs (with effective track transforms) + parts (with built
2069
- * chains) into flat module probes plus a per-part adjacency map. Shared by
2070
- * the imperative ref and the reactive monitor so the world-matrix
2071
- * composition lives in one place.
2072
- *
2073
- * Hidden XPUs / parts (`visible === false`) are skipped to match the scene.
2074
- */
2075
- declare function buildModuleProbes(orientation: Orientation | undefined, xpus: ReadonlyArray<ModuleCollisionXpuInput>, opts?: CheckModuleCollisionsOptions): {
2076
- probes: ModuleProbe[];
2077
- adjacency: Map<number, {
2078
- count: number;
2079
- closed: boolean;
2080
- }>;
2081
- };
2082
- /**
2083
- * Pairwise OBB overlap test over all module probes. Same-part consecutive
2084
- * and closed-loop-seam pairs are excluded; everything else is tested.
2085
- * Returns one entry per overlapping (or near-miss, when `warningGapMm > 0`)
2086
- * pair, sorted deepest-overlap-first.
2087
- */
2088
- declare function checkModuleCollisions(probes: ReadonlyArray<ModuleProbe>, adjacency: ReadonlyMap<number, {
2089
- count: number;
2090
- closed: boolean;
2091
- }>, opts?: CheckModuleCollisionsOptions): ModuleCollision[];
2092
- /**
2093
- * Resolve a normalized config into the per-XPU inputs `buildModuleProbes`
2094
- * takes, applying the same override precedence the scene uses: an imperative
2095
- * `trackTransformOverrides` entry beats the config value.
2096
- *
2097
- * This adapter was written out twice, character for character — once in
2098
- * `<ModuleCollisionMonitor>` and once inside the `checkModuleCollisions`
2099
- * closure of `useXtsViewerHandle` — with the only difference being where the
2100
- * orientation came from.
2101
- *
2102
- * A part whose chain cannot be built is skipped, as before, but no longer in
2103
- * silence: `normalizeXtsConfig` has already made that impossible, so if it
2104
- * ever happens it means a part has silently dropped out of collision
2105
- * detection, which is a safety feature. Reported rather than thrown, because
2106
- * both callers run inside React and a throw would take the scene down.
2461
+ * Live override of the per-asset origin-correction sidecars, keyed by asset
2462
+ * type. Useful for calibration tooling that adjusts a model's origin without
2463
+ * re-publishing its `.meta.json`.
2107
2464
  */
2108
- declare function toModuleCollisionInputs(config: NormalizedXtsConfig, trackTransformOverrides?: Record<number, TrackTransform>): ModuleCollisionXpuInput[];
2109
- /** `toModuleCollisionInputs` → `buildModuleProbes` → `checkModuleCollisions`. */
2110
- declare function checkAllModuleCollisions(config: NormalizedXtsConfig, orientation: Orientation | undefined, trackTransformOverrides?: Record<number, TrackTransform>, opts?: CheckModuleCollisionsOptions): ModuleCollision[];
2465
+ interface SidecarOverrides {
2466
+ modules?: Partial<Record<ModuleType3D, OriginCorrection>>;
2467
+ movers?: Partial<Record<MoverType3D, OriginCorrection>>;
2468
+ tools?: Partial<Record<MoverToolType3D, OriginCorrection>>;
2469
+ magnetPlates?: Partial<Record<MagnetPlateType3D, OriginCorrection>>;
2470
+ rails?: Partial<Record<RailType3D, OriginCorrection>>;
2471
+ }
2111
2472
 
2112
2473
  /**
2113
2474
  * Shared, mostly-pure plumbing behind `exportScreenshot` and
@@ -2525,7 +2886,7 @@ declare function trackPolylineWorld(entry: TrackIndexEntry, fromChainMm: number,
2525
2886
  * `getLivePositionMm` supplies the imperative store value; without it the
2526
2887
  * config-time position is used.
2527
2888
  */
2528
- declare function moverAnchorWorld(config: NormalizedXtsConfig, index: TrackIndex, ref: MoverRef, getLivePositionMm?: (moverId: number) => number | undefined): Vec3 | null;
2889
+ declare function moverAnchorWorld(config: NormalizedXtsConfig, index: TrackIndex, ref: MoverRef, getLivePositionMm?: (moverId: number) => number | undefined, getLivePartObjectId?: (moverId: number) => number | undefined): Vec3 | null;
2529
2890
  /** World centre of a module, for `AnnotationAnchor` of kind `'module'`. */
2530
2891
  declare function moduleAnchorWorld(index: TrackIndex, processingUnitObjectId: number, partObjectId: number, moduleIndex: number): Vec3 | null;
2531
2892
  /** World position of a `TrackAnchor` (user-frame mm on a part). */
@@ -2561,7 +2922,7 @@ interface MeasurementApi {
2561
2922
  /** Close the current pick — the polyline tool's "done". */
2562
2923
  finish(): Measurement | null;
2563
2924
  add(measurement: MeasurementInput): Measurement;
2564
- update(id: MeasurementId, patch: Partial<Omit<Measurement, 'id' | 'kind'>>): Measurement | null;
2925
+ update(id: MeasurementId, patch: MeasurementPatch): Measurement | null;
2565
2926
  remove(id: MeasurementId): boolean;
2566
2927
  clear(): void;
2567
2928
  /** Replace the whole list (e.g. restoring a persisted session). */
@@ -2619,15 +2980,6 @@ interface XtsViewer3DRef {
2619
2980
  focusOn(target: FocusTarget, opts?: FocusOptions): void;
2620
2981
  setCamera(cam: CameraState): void;
2621
2982
  getCamera(): CameraState | null;
2622
- /**
2623
- * @deprecated Not implemented — this is a no-op that warns in development.
2624
- *
2625
- * `orientation` is a static prop in this build; there is no state behind
2626
- * it to update. Set it through `<XtsViewer3D orientation={…}>` instead.
2627
- * The method is kept only so the published interface does not change
2628
- * shape mid-major, and will be removed in the next major.
2629
- */
2630
- setOrientation(orientation: XtsConfig['orientation']): void;
2631
2983
  getMoverWorldTransform(ref: MoverRef): {
2632
2984
  positionMm: [number, number, number];
2633
2985
  rotationDegEuler: [number, number, number];
@@ -2691,16 +3043,6 @@ interface XtsViewer3DRef {
2691
3043
  * frameloop tick.
2692
3044
  */
2693
3045
  renderFrameNow(): void;
2694
- /**
2695
- * @deprecated Not implemented — this is a no-op that warns in development.
2696
- *
2697
- * GLB caching lives in `useGLTF`'s global cache, which r3f exposes no
2698
- * per-instance handle to clear. Use `useGLTF.clear()` from
2699
- * `@react-three/drei` directly if you need to drop cached assets. Kept
2700
- * only so the published interface does not change shape mid-major, and
2701
- * will be removed in the next major.
2702
- */
2703
- reloadAssets(): Promise<void>;
2704
3046
  setMoverPosition(moverId: number, partPositionMm: number): void;
2705
3047
  /**
2706
3048
  * Push mover positions in bulk. Two accepted forms:
@@ -2724,6 +3066,26 @@ interface XtsViewer3DRef {
2724
3066
  setModuleStatuses(statuses: ReadonlyArray<ModuleStatusEntry | null | undefined>): void;
2725
3067
  /** Clear all live module statuses (revert to config-time `ModuleEntry.status`). */
2726
3068
  clearModuleStatuses(): void;
3069
+ /**
3070
+ * Push live mover-tool state, keyed by `MoverConfig.id` and then by
3071
+ * `MoverToolConfig.id` — `update[mover.id][tool.id] = { visible }`.
3072
+ *
3073
+ * For state that changes while the scene runs: a simulated line loads a
3074
+ * product onto a mover at one station and takes it off at another, so the
3075
+ * tool drawing that product appears and disappears several times a minute.
3076
+ * Saying it through `config` would replace the whole configuration on every
3077
+ * change; this is one call and one `invalidate()`, however many movers it
3078
+ * carries.
3079
+ *
3080
+ * A live entry OVERRIDES the config-time `MoverToolConfig.visible`. `null`
3081
+ * for a tool drops it back to the configuration. Only movers named in
3082
+ * `update` are touched, and only the movers that actually changed
3083
+ * re-render. A tool with no `id` cannot be addressed and always follows its
3084
+ * configuration.
3085
+ */
3086
+ setMoverToolStates(update: Readonly<Record<number, Readonly<Record<string, MoverToolStateEntry | null>>>>): void;
3087
+ /** Clear all live mover-tool state (revert to config-time `MoverToolConfig.visible`). */
3088
+ clearMoverToolStates(): void;
2727
3089
  checkMoverCollisions(opts?: CheckMoverCollisionsOptions): MoverCollision[];
2728
3090
  /**
2729
3091
  * On-demand check for physically overlapping track MODULES across the
@@ -2757,8 +3119,8 @@ interface XtsViewer3DRef {
2757
3119
  }
2758
3120
 
2759
3121
  /**
2760
- * HepcoGfxRailProfile — cross-section of the Hepco GFX guiding rail used by
2761
- * the procedural extrusion in <HepcoGfxRail>.
3122
+ * HepcoGfxRailProfile — cross-sections of the Hepco GFX 1-Trak (TL1) guideway
3123
+ * used by the procedural extrusion in <HepcoGfxRail>.
2762
3124
  *
2763
3125
  * The profile is expressed in the Beckhoff path-frame convention:
2764
3126
  * - path tangent = +X (extrusion direction)
@@ -2770,76 +3132,159 @@ interface XtsViewer3DRef {
2770
3132
  * plane the initial Frenet frame computed by three.js is:
2771
3133
  * normal = (0, 0, -1) -> shape.x maps to world -Z
2772
3134
  * binormal = lateral in XY -> shape.y maps to world ±Y
2773
- * To keep this module's public dimensions in natural "Z-up" coordinates,
2774
- * the Shape factory negates Z when emitting shape.x. Callers should think
2775
- * in (lateral Y, vertical Z); the negation is an implementation detail.
3135
+ * To keep this module's public dimensions in natural "Z-up" coordinates, the
3136
+ * outlines below are built as (y, z) point lists and only converted to the
3137
+ * shape frame by `toShape()`. All the geometry — including the arcs — is
3138
+ * therefore expressed and testable in natural terms; the negation of Z is an
3139
+ * implementation detail of one function.
3140
+ *
3141
+ * The guideway cross-section is THREE disconnected regions, one mesh each:
3142
+ *
3143
+ * 1. Baseplate — a flat plate, 30 mm thick. The real part is 270 mm wide and
3144
+ * carries both tracks of the oval; the viewer extrudes one 135 mm half per
3145
+ * Part, so two Parts (or the two straights of one closed Part) together
3146
+ * span the full width. Its inner edge carries half of the 30 mm central
3147
+ * slot; the other half comes from the opposite track.
3148
+ *
3149
+ * 2. Rail — the hardened steel V-rail the mover's rollers engage. Apex to
3150
+ * apex 44 mm ("NM44"), 15.6 mm tall, with 35° V flanks, a shallow groove
3151
+ * in its top face, a waisted neck and two feet standing on the plate.
3152
+ *
3153
+ * 3. Bracket — the GFX-UMB upper mounting bracket that carries the XTS motor
3154
+ * modules above the plate. One per Part, on the inner side of the rail.
2776
3155
  *
2777
- * The Hepco GFX cross-section is TWO disconnected regions:
3156
+ * ## Where the numbers come from
2778
3157
  *
2779
- * 1. Baseplate — a wide flat plate at the bottom (134.45 mm × 30.1 mm).
2780
- * One per track; the two plates of an XTS oval sit side-by-side and
2781
- * together span the full ~268.9 mm system width.
3158
+ * Every dimension below is measured off the original CAD, not estimated:
3159
+ * `stepfiles/hepco-gfx-rail/BA-GFX-1TL-3M-B.stp`, which is an assembly of
3160
+ * named parts — `GFX-1TLBP-3S-B` (baseplate), `AT-XTS_GFX-NM44-L500` (straight
3161
+ * rail), `GFX-SEG-NM44-CSA_` (curved rail), `GFX-UMB-1RH`/`-2LH`/`-EB`
3162
+ * (brackets and end block). The same section was cross-checked against
3163
+ * HepcoMotion's own `GFX-1TL-8M-B` download, which agrees to 0.1 mm.
2782
3164
  *
2783
- * 2. Rail — a separate spear-profile bar (40 mm × 20 mm) sitting on top
2784
- * of the baseplate at its +Y edge. Has horizontally pointed apexes on
2785
- * both lateral sides; the mover's V-rollers engage these apexes.
3165
+ * ## The one constraint that must not move
2786
3166
  *
2787
- * Two shape factories are exported so the consumer can extrude each region
2788
- * as its own mesh; merging them into a single Shape would require a thin
2789
- * connecting sliver that misrepresents the real geometry.
3167
+ * All nine Hepco carriage sidecars (`public/models/Hepco-GFX2-*.meta.json`)
3168
+ * are calibrated to seat their V-roller grooves on the two apexes at
3169
+ * chain-frame (Y, Z) = (9, -43) and (-35, -43), with only 0.7-1.6 mm of
3170
+ * clearance left. The apexes are the datum: every other dimension here is
3171
+ * derived relative to them, so the carriages stay seated.
2790
3172
  *
2791
- * The `stackYOffsetMm` / `stackZOffsetMm` fields shift both regions
2792
- * together — used by the playground calibration UI to fine-tune position
2793
- * relative to the path origin without changing the inherent geometry.
3173
+ * apex centre Y = stackYOffsetMm = -13
3174
+ * apex Z = railTopZMm - spearBaseHalfHeightMm
3175
+ * + stackZOffsetMm = -43
3176
+ * plate top Z = railTopZMm - railHeightMm + stackZOffsetMm = -55.5
3177
+ *
3178
+ * `HepcoGfxRailProfile.test.ts` pins this contract directly.
2794
3179
  */
2795
3180
 
3181
+ /** A point in the natural (lateral Y, vertical Z) profile frame, in mm. */
3182
+ type ProfilePoint = readonly [y: number, z: number];
2796
3183
  interface HepcoGfxRailProfileDims {
2797
- /** Baseplate Y-extent per track (mm). Half of the XTS oval's full
2798
- * cross-section width. */
3184
+ /** Baseplate Y-extent per track (mm) — half of the real 270 mm plate. */
2799
3185
  readonly baseplateWidthMm: number;
2800
3186
  /** Baseplate Z-extent (mm). */
2801
3187
  readonly baseplateThicknessMm: number;
2802
- /** Z-coordinate of the baseplate's top face in the path frame (before
2803
- * stackZOffsetMm is applied). Also the Z of the rail's bottom face. */
3188
+ /**
3189
+ * Z of the baseplate's top face in the path frame (before stackZOffsetMm).
3190
+ * Also the Z the rail and the bracket stand on.
3191
+ */
2804
3192
  readonly baseplateTopZMm: number;
2805
- /** Rail Y-extent, spear-apex to spear-apex (mm). */
3193
+ /**
3194
+ * Lateral distance from the rail centreline to the plate's OUTER edge (mm),
3195
+ * on the +Y side. The real rail overhangs the plate edge, so this is smaller
3196
+ * than half the rail width. The inner edge follows from `baseplateWidthMm`.
3197
+ */
3198
+ readonly baseplateOuterExtentMm: number;
3199
+ /**
3200
+ * Depth of the central-slot notch cut into the plate's inner edge (mm) —
3201
+ * half of the real 30 mm slot, the other half belonging to the opposite
3202
+ * track's plate.
3203
+ */
3204
+ readonly baseplateSlotDepthMm: number;
3205
+ /** Length of one central slot along the path (mm). */
3206
+ readonly baseplateSlotLengthMm: number;
3207
+ /** Spacing of the central slots along the path (mm). */
3208
+ readonly baseplateSlotPitchMm: number;
3209
+ /** Rail Y-extent, apex to apex (mm). */
2806
3210
  readonly railWidthMm: number;
2807
- /** Rail Z-extent, main-body top to main-body bottom (mm). */
3211
+ /** Rail Z-extent, top face down to the feet (mm). */
2808
3212
  readonly railHeightMm: number;
2809
- /** Z-coordinate of the rail's main-body top in the path frame (before
2810
- * stackZOffsetMm is applied). */
3213
+ /** Z of the rail's top face in the path frame (before stackZOffsetMm). */
2811
3214
  readonly railTopZMm: number;
2812
- /** Lateral protrusion of each spear apex beyond the rail's main body (mm). */
2813
- readonly spearProtrusionMm: number;
2814
- /** Half-height of the spear at its inner (main-body-side) edge (mm) —
2815
- * measured from the apex Z toward both top and bottom shoulders. */
3215
+ /**
3216
+ * Drop from the rail's top face to the apex centre (mm). Together with
3217
+ * `railTopZMm` and `stackZOffsetMm` this fixes the apex height, which the
3218
+ * carriage sidecars depend on — see the module header.
3219
+ */
2816
3220
  readonly spearBaseHalfHeightMm: number;
2817
- /** Concave-undercut inset on the rail's bottom face just inboard of
2818
- * each spear (mm) — the rail's bottom edge is shorter than its top
2819
- * edge by 2× this value. */
2820
- readonly undercutInsetMm: number;
2821
- /** Calibration shift in Y applied to baseplate + rail together (mm). */
3221
+ /** Height of the small vertical flat at the very tip of each apex (mm). */
3222
+ readonly apexFlatHeightMm: number;
3223
+ /** V flank angle measured from horizontal (deg). Both flanks are equal. */
3224
+ readonly vFlankAngleDeg: number;
3225
+ /** Height of the rail's underside wings above the plate (mm). */
3226
+ readonly railWingHeightMm: number;
3227
+ /** Half-width of the underside wing flat, inner end (mm). */
3228
+ readonly railWingInnerHalfWidthMm: number;
3229
+ /** Width of the recessed groove in the rail's top face (mm). */
3230
+ readonly topGrooveWidthMm: number;
3231
+ /** Depth of that groove below the top face (mm). */
3232
+ readonly topGrooveDepthMm: number;
3233
+ /** Radius of the two tangent arcs forming each groove edge (mm). */
3234
+ readonly topGrooveFilletMm: number;
3235
+ /** Convex round below each wing: centre (half-width, height) and radius. */
3236
+ readonly neckRoundCentreMm: ProfilePoint;
3237
+ readonly neckRoundRadiusMm: number;
3238
+ /** Concave waist scallop: centre (half-width, height) and radius. */
3239
+ readonly neckWaistCentreMm: ProfilePoint;
3240
+ readonly neckWaistRadiusMm: number;
3241
+ /** Where the convex round hands over to the concave waist (half-width, height). */
3242
+ readonly neckJunctionMm: ProfilePoint;
3243
+ /** Half-width of the rail feet at their outer wall (mm). */
3244
+ readonly footOuterHalfWidthMm: number;
3245
+ /** Height at which the foot's outer wall meets the waist scallop (mm). */
3246
+ readonly footTopHeightMm: number;
3247
+ /** 45° chamfer on every foot corner at the plate face (mm). */
3248
+ readonly footChamferMm: number;
3249
+ /** Half-width of the channel between the two feet (mm). */
3250
+ readonly channelHalfWidthMm: number;
3251
+ /** Height of that channel (mm) — its floor is the plate's top face. */
3252
+ readonly channelHeightMm: number;
3253
+ /** Bracket footprint width (mm). */
3254
+ readonly bracketWidthMm: number;
3255
+ /** Bracket total height above the plate's top face (mm). */
3256
+ readonly bracketHeightMm: number;
3257
+ /** Height of the bracket's full-width base block (mm). */
3258
+ readonly bracketBaseHeightMm: number;
3259
+ /** Width of the upper wall standing on the base block's outer edge (mm). */
3260
+ readonly bracketWallWidthMm: number;
3261
+ /**
3262
+ * Lateral distance from the rail centreline to the bracket's OUTER face,
3263
+ * measured toward -Y (mm). The bracket sits between the rail and the oval's
3264
+ * centreline, and the XTS module bolts to the outer face of its wall.
3265
+ */
3266
+ readonly bracketOuterOffsetMm: number;
3267
+ /** Calibration shift in Y applied to every region together (mm). */
2822
3268
  readonly stackYOffsetMm: number;
2823
- /** Calibration shift in Z applied to baseplate + rail together (mm). */
3269
+ /** Calibration shift in Z applied to every region together (mm). */
2824
3270
  readonly stackZOffsetMm: number;
3271
+ /**
3272
+ * @deprecated Described the straight-chamfer approximation of the old
3273
+ * scaffolding profile. No longer read by any shape factory; kept so that
3274
+ * callers passing it through `hepcoGfxProfile` keep type-checking.
3275
+ */
3276
+ readonly spearProtrusionMm: number;
3277
+ /**
3278
+ * @deprecated See `spearProtrusionMm`. The real undercut is the waisted
3279
+ * neck described by `neckWaist*` / `neckRound*`.
3280
+ */
3281
+ readonly undercutInsetMm: number;
2825
3282
  }
2826
3283
  /**
2827
- * Default dimensions (mm). The stack offsets bake in the calibration so the
2828
- * rendered geometry is correct out of the box; the playground / a
2829
- * `*.meta.json` sidecar (`Hepco-GFX.rail.meta.json`) can override them.
2830
- *
2831
- * Cross-checked against the exact Hepco TL1 (1-Trak loop) guideway STP
2832
- * `stepfiles/hepco-gfx-rail/BA-GFX-1TL-3M-B.stp`: a perpendicular section of
2833
- * a straight run shows the full beam is ~270 mm wide in the lateral axis
2834
- * (matches `baseplateWidthMm` 134.45 × 2 tracks = 268.9 mm) and the steel
2835
- * guide-rail block the V-rollers engage is ~37 mm wide × ~18 mm tall (close to
2836
- * `railWidthMm` 44 / `railHeightMm` 15 here). The tall central structure in
2837
- * the STP is the motor stator track, which the viewer renders as the module
2838
- * GLB — not part of this guide-rail profile.
3284
+ * Default dimensions (mm), measured from the CAD named in the module header.
2839
3285
  *
2840
- * These values are kept in sync with the calibrated runtime override in
2841
- * `public/models/Hepco-GFX.rail.meta.json`. PRT2 (22.5° / 45°) currently has
2842
- * no dedicated STP and reuses this TL1-derived profile.
3286
+ * Kept in sync by hand with `public/models/Hepco-GFX.rail.meta.json`, which the
3287
+ * playground loads so the calibration panel can override them at runtime.
2843
3288
  */
2844
3289
  declare const HEPCO_GFX_PROFILE: HepcoGfxRailProfileDims;
2845
3290
  /**
@@ -2849,22 +3294,64 @@ declare const HEPCO_GFX_PROFILE: HepcoGfxRailProfileDims;
2849
3294
  */
2850
3295
  declare function resolveHepcoGfxProfile(override?: Partial<HepcoGfxRailProfileDims>): HepcoGfxRailProfileDims;
2851
3296
  /**
2852
- * Build the baseplate cross-section (mm). Simple rectangle positioned so
2853
- * the rail's +Y spear apex sits flush with the baseplate's +Y edge; the
2854
- * baseplate extends in -Y direction from there. Both axes are shifted by
2855
- * `stackYOffsetMm` / `stackZOffsetMm` before being emitted.
3297
+ * Build the rail's closed outline in natural (Y, Z) coordinates, before the
3298
+ * stack offsets are applied. Exported so tests and calibration tooling can
3299
+ * reason about the real cross-section without going through the shape frame.
3300
+ *
3301
+ * Traversal starts at the top face's -Y end and runs to +Y, down the +Y flank,
3302
+ * along the underside (through the channel between the two feet) and back up
3303
+ * the -Y flank — the same winding the previous scaffolding outline used.
3304
+ */
3305
+ declare function buildHepcoGfxRailOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
3306
+ /**
3307
+ * Build the baseplate outline in natural (Y, Z) coordinates.
3308
+ *
3309
+ * `slotDepthMm` trims the plate's inner edge back by that much — used to
3310
+ * extrude the plate's continuous web, with `buildHepcoGfxBaseplateBridgeOutline`
3311
+ * filling the trimmed strip back in between the central slots. Pass 0 (the
3312
+ * default) for the plain full-width section.
3313
+ */
3314
+ declare function buildHepcoGfxBaseplateOutline(dims?: HepcoGfxRailProfileDims, slotDepthMm?: number): ProfilePoint[];
3315
+ /**
3316
+ * The strip the central slot removes from the plate's inner edge. Extruded in
3317
+ * short runs between the slots so the slots come out as real openings rather
3318
+ * than painted-on darkness.
3319
+ */
3320
+ declare function buildHepcoGfxBaseplateBridgeOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
3321
+ /**
3322
+ * Build the GFX-UMB bracket outline: a full-width base block with the upper
3323
+ * wall standing on its outer edge, so the section is an L opening toward the
3324
+ * oval's centreline.
2856
3325
  */
2857
- declare function createHepcoGfxBaseplateShape(dims?: HepcoGfxRailProfileDims): Shape;
3326
+ declare function buildHepcoGfxBracketOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
3327
+ /** The bracket's upper wall alone — the part the windows interrupt. */
3328
+ declare function buildHepcoGfxBracketWallOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
2858
3329
  /**
2859
- * Build the rail cross-section (mm). Symmetric spear-profile bar centered
2860
- * on Y=0 (before stackYOffsetMm). The mover's V-rollers grip the two
2861
- * spear apexes (at Y=±railWidth/2, Z=midZ).
3330
+ * The GFX-UMB-EB end block that caps an open end of the bracket run: the
3331
+ * bracket's full width, but only over the upper wall's height band.
2862
3332
  *
2863
- * The profile's top edge is flush with the spear shoulder top — there is
2864
- * NO rectangular section above the spear shoulders. `railHeightMm` therefore
2865
- * spans only from the main-body bottom up to the spear shoulder top.
3333
+ * The real part is 84.7 mm wide and ties the Part's bracket to its opposite
3334
+ * number across the oval's centreline. The viewer draws one bracket per Part,
3335
+ * so it gets the half that belongs to this one — full bracket width, which is
3336
+ * what closes the visible end of the wall.
2866
3337
  */
3338
+ declare function buildHepcoGfxBracketEndBlockOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
3339
+ /** The bracket's base block alone — continuous under the windowed wall. */
3340
+ declare function buildHepcoGfxBracketBaseOutline(dims?: HepcoGfxRailProfileDims): ProfilePoint[];
3341
+ /** Baseplate cross-section (mm). See `buildHepcoGfxBaseplateOutline`. */
3342
+ declare function createHepcoGfxBaseplateShape(dims?: HepcoGfxRailProfileDims, slotDepthMm?: number): Shape;
3343
+ /** Baseplate inner-edge strip between two central slots (mm). */
3344
+ declare function createHepcoGfxBaseplateBridgeShape(dims?: HepcoGfxRailProfileDims): Shape;
3345
+ /** Rail cross-section (mm). See `buildHepcoGfxRailOutline`. */
2867
3346
  declare function createHepcoGfxRailShape(dims?: HepcoGfxRailProfileDims): Shape;
3347
+ /** Full GFX-UMB bracket cross-section (mm). */
3348
+ declare function createHepcoGfxBracketShape(dims?: HepcoGfxRailProfileDims): Shape;
3349
+ /** GFX-UMB base block cross-section (mm). */
3350
+ declare function createHepcoGfxBracketBaseShape(dims?: HepcoGfxRailProfileDims): Shape;
3351
+ /** GFX-UMB upper wall cross-section (mm). */
3352
+ declare function createHepcoGfxBracketWallShape(dims?: HepcoGfxRailProfileDims): Shape;
3353
+ /** GFX-UMB-EB end block cross-section (mm). */
3354
+ declare function createHepcoGfxBracketEndBlockShape(dims?: HepcoGfxRailProfileDims): Shape;
2868
3355
 
2869
3356
  /**
2870
3357
  * <XtsViewer3D> — Top-level React component.
@@ -2913,30 +3400,38 @@ interface XtsViewer3DProps {
2913
3400
  /**
2914
3401
  * Live camera projection. Default `'perspective'`.
2915
3402
  *
2916
- * `'orthographic'` (together with `topDown`, on by default) renders the flat
2917
- * 2D plan view — straight down +Z, up = +Y — pixel-consistent with
3403
+ * `'orthographic'` renders the flat 2D plan view — straight down +Z, up = +Y — pixel-consistent with
2918
3404
  * `exportScreenshot({ mode: 'top-down' })`. Switching at runtime does NOT
2919
3405
  * recreate the WebGL context. In orthographic top-down, rotation is
2920
3406
  * auto-disabled (pan + zoom stay on) unless you set `lock.rotate` explicitly.
2921
3407
  */
2922
3408
  projection?: CameraProjection;
2923
- /**
2924
- * When `projection === 'orthographic'`, frame the scene straight from above.
2925
- * Default `true`. (Only `true` is meaningful today; reserved for future
2926
- * orthographic side/front views.)
2927
- */
2928
- topDown?: boolean;
2929
3409
  /** Lock interactions. */
2930
3410
  lock?: InteractionLocks;
3411
+ /**
3412
+ * Move the camera through the scene with the keyboard instead of only
3413
+ * orbiting it: arrow keys / WASD walk along the ground plane, PageUp /
3414
+ * PageDown climb, Shift is faster and Alt finer. On by default; the canvas
3415
+ * takes focus on pointer-down, so the keys arm themselves on the first
3416
+ * click into the viewer.
3417
+ */
3418
+ keyboardNavigation?: KeyboardNavigationOptions;
2931
3419
  /** Selection-Mode. Default: 'Off'. */
2932
3420
  selectionMode?: SelectionMode;
3421
+ /**
3422
+ * What a click picks out. Default `'module'` — the module or mover under
3423
+ * the pointer, as before.
3424
+ *
3425
+ * `'track'` selects the whole processing unit instead, whichever part of
3426
+ * it was hit: that is the granularity the transform gizmo works at, since
3427
+ * a single module has no pose of its own. Static custom assets are always
3428
+ * selected in their own right, in either scope.
3429
+ */
3430
+ selectionScope?: SelectionScope;
2933
3431
  /** Controlled selection. */
2934
3432
  selection?: SelectionState;
2935
3433
  /** Selection-Highlight-Farben. */
2936
- selectionColors?: {
2937
- module?: string;
2938
- mover?: string;
2939
- };
3434
+ selectionColors?: SelectionColors;
2940
3435
  /**
2941
3436
  * Declarative module highlights — tint all modules matching the given
2942
3437
  * types with the specified color. Works independently of selection:
@@ -3007,6 +3502,8 @@ interface XtsViewer3DProps {
3007
3502
  onCameraChange?: (c: CameraState) => void;
3008
3503
  onMoverClick?: (m: MoverRef) => void;
3009
3504
  onModuleClick?: (m: ModuleRef) => void;
3505
+ /** A click on a static custom asset (`binding.type === 'static'`). */
3506
+ onCustomAssetClick?: (a: CustomAssetRef) => void;
3010
3507
  onMoverHover?: (m: MoverRef | null) => void;
3011
3508
  onModuleHover?: (m: ModuleRef | null) => void;
3012
3509
  onError?: (err: XtsViewerError) => void;
@@ -3030,30 +3527,35 @@ interface XtsViewer3DProps {
3030
3527
  hepcoGfxProfile?: Partial<HepcoGfxRailProfileDims>;
3031
3528
  /** CAD-style ViewCube overlay. */
3032
3529
  viewCube?: ViewCubeOptions;
3530
+ /**
3531
+ * Translate / rotate / scale handles on the selection — a whole track, or
3532
+ * a static custom asset picked in its own right (issue #107). A single
3533
+ * module or mover has no pose of its own and never gets handles, which is
3534
+ * what `selectionScope: 'track'` is for. Off by default; see
3535
+ * `TransformGizmoOptions` for what a drag reports and how it reaches the
3536
+ * configuration.
3537
+ *
3538
+ * @example
3539
+ * ```tsx
3540
+ * <XtsViewer3D
3541
+ * selectionMode="Single"
3542
+ * selectionScope="track"
3543
+ * transformGizmo={{ enabled: true, mode: 'translate' }}
3544
+ * />
3545
+ * ```
3546
+ */
3547
+ transformGizmo?: TransformGizmoOptions;
3033
3548
  /** Stator heatmap data — coloured tube along each part's centerline. */
3034
3549
  statorHeatmap?: StatorHeatmap;
3035
- /** Continuous mover collision monitoring. */
3036
- collisionDetection?: {
3037
- enabled?: boolean;
3038
- warningGapMm?: number;
3039
- intervalMs?: number;
3040
- forceClosedLoop?: boolean;
3041
- onCollisionsChange?: (collisions: MoverCollision[]) => void;
3042
- };
3043
3550
  /**
3044
- * Static MODULE overlap monitoring. Detects track modules that physically
3045
- * overlap in 3D space — different XPUs / parts placed via `trackTransform`
3046
- * / `orientation` / `partTransformation`, plus non-adjacent modules of the
3047
- * same part. Adjacent (seam) modules and the closed-loop seam are
3048
- * excluded. Recomputed on config / track-transform change (NOT per-frame,
3049
- * since modules don't move at 60 Hz) — `onCollisionsChange` fires only
3050
- * when the overlap set actually changes.
3551
+ * Collision monitoring, for movers and for modules.
3552
+ *
3553
+ * One prop with two halves rather than two near-identical props: both
3554
+ * carry `enabled`, `warningGapMm` and `onCollisionsChange`, and a caller
3555
+ * reading the API had no way to tell from the names that
3556
+ * `collisionDetection` meant movers specifically.
3051
3557
  */
3052
- moduleCollisionDetection?: {
3053
- enabled?: boolean;
3054
- warningGapMm?: number;
3055
- onCollisionsChange?: (collisions: ModuleCollision[]) => void;
3056
- };
3558
+ collision?: CollisionOptions;
3057
3559
  /**
3058
3560
  * CAD-style measurement tools + annotations, in the 3D view and the 2D
3059
3561
  * plan view alike.
@@ -3111,9 +3613,72 @@ interface MoverCatalogEntry {
3111
3613
  magnetPlateCenterMm: Vec3;
3112
3614
  }
3113
3615
  declare const MOVER_CATALOG: Readonly<Record<MoverType3D, MoverCatalogEntry>>;
3616
+ /**
3617
+ * The magnet plate set already modelled into each mover's own GLB, or `null`
3618
+ * for a carriage that ships bare and needs one attached.
3619
+ *
3620
+ * Nearly every mover asset is a combined carriage+plate assembly: the Beckhoff
3621
+ * movers say so in the filename (`AT9014-0055-0550` carries an AT9001-0550),
3622
+ * and each Hepco carriage records the plate it was ordered with in its
3623
+ * sidecar's `_calibration.hepcoPartNumber` (`…_AT9001-0775` and friends).
3624
+ * Attaching a second plate to one of those would draw a plate through a plate,
3625
+ * so `<XtsMoverPlate>` renders only where this table says `null`.
3626
+ *
3627
+ * `Custom` is `null` because its geometry comes from `customMoverLayout`,
3628
+ * which may or may not include a plate — the host decides.
3629
+ *
3630
+ * Exhaustive over `MoverType3D` on purpose (`Record`, not `Partial<Record>`):
3631
+ * a new mover type has to state which it is, so it cannot silently end up
3632
+ * double-plated or plate-less.
3633
+ */
3634
+ declare const MOVER_INTEGRATED_PLATE: Readonly<Record<MoverType3D, MagnetPlateType3D | null>>;
3635
+ /**
3636
+ * Plate to attach when a bare carriage has not been given one explicitly.
3637
+ * The 5-pole AT9001-0550 is what the equivalent `_C25` carriage ships with,
3638
+ * and it models the encoder flag the bare S25 is otherwise missing.
3639
+ */
3640
+ declare const DEFAULT_MAGNET_PLATE_TYPE: MagnetPlateType3D;
3641
+ /**
3642
+ * The plate that should render on top of `moverType`, or `null` when the
3643
+ * mover already has one of its own (or is `Custom`).
3644
+ */
3645
+ declare function resolveAttachedPlateType(moverType: MoverType3D, configured?: MagnetPlateType3D): MagnetPlateType3D | null;
3114
3646
  declare function getMoverEntry(moverType: MoverType3D): MoverCatalogEntry | undefined;
3115
3647
  declare function isKnownMoverType(value: string): value is MoverType3D;
3116
3648
 
3649
+ /**
3650
+ * Magnet plate set catalog (3D-canonical).
3651
+ *
3652
+ * A plate set is the part that carries the mover's magnets. Most mover GLBs
3653
+ * ship with one already modelled in (see `MOVER_INTEGRATED_PLATE`); these
3654
+ * entries describe the sets that exist as assets of their own, so a bare
3655
+ * carriage can be given any pole count without a new combined CAD assembly.
3656
+ *
3657
+ * `lengthMm` is the plate's extent along the travel axis, measured from the
3658
+ * converted GLB rather than inferred from the part number. Pole count was
3659
+ * likewise counted in the mesh: each magnet is 7.7 mm wide and sits in a row
3660
+ * just proud of its backing plate, so the magnets separate cleanly along the
3661
+ * travel axis. That count is what establishes the part-number rule (the digit
3662
+ * after the dash is the pole count in base 16) — it is not assumed from it.
3663
+ */
3664
+
3665
+ interface MagnetPlateCatalogEntry {
3666
+ plateType: MagnetPlateType3D;
3667
+ /** Magnets in one row, i.e. the pole count the part number encodes. */
3668
+ poleCount: number;
3669
+ /** Plate extent along the travel axis, mm (GLB-measured). */
3670
+ lengthMm: number;
3671
+ /**
3672
+ * Whether the set models the encoder flag ("Geberfahne"). The AT9001 sets
3673
+ * all carry it as a thin sheet reaching back along −Z; the hygienic ATH9001
3674
+ * sets encapsulate their rows in sealed stainless and model none.
3675
+ */
3676
+ hasEncoderFlag: boolean;
3677
+ }
3678
+ declare const MAGNET_PLATE_CATALOG: Readonly<Record<MagnetPlateType3D, MagnetPlateCatalogEntry>>;
3679
+ declare function getMagnetPlateEntry(plateType: MagnetPlateType3D): MagnetPlateCatalogEntry | undefined;
3680
+ declare function isKnownMagnetPlateType(value: unknown): value is MagnetPlateType3D;
3681
+
3117
3682
  /**
3118
3683
  * Module-local path math.
3119
3684
  *
@@ -3155,33 +3720,21 @@ interface EndDelta {
3155
3720
  declare function moduleEndDelta(module: ModuleCatalogEntry): EndDelta;
3156
3721
 
3157
3722
  /**
3158
- * XtsArcCurve3 — Three.js Curve3 wrapper around the analytic Curve sample.
3723
+ * XtsModuleCurve3 — Three.js Curve3 wrapper around a module's centreline.
3159
3724
  *
3160
- * Each module exposes a THREE.Curve<Vector3> that the part-level CurvePath
3161
- * chains together. For analytic Curves we just wrap the math from
3162
- * pathMath.ts; for Free paths see XtsPointCloudCurve3.
3163
- *
3164
- * Note: CurvePath delegates to subcurve length-proportional, so `getLength()`
3165
- * MUST return the actual arc length so subcurves are weighted correctly.
3166
- * Our subcurves are arc-length parametrised (t = s / L), so this just-works.
3167
- */
3168
-
3169
- declare class XtsArcCurve3 extends Curve<Vector3> {
3170
- readonly module: ModuleCatalogEntry;
3171
- constructor(module: ModuleCatalogEntry);
3172
- getPoint(t: number, optionalTarget?: Vector3): Vector3;
3173
- getTangent(t: number, optionalTarget?: Vector3): Vector3;
3174
- getLength(): number;
3175
- }
3176
-
3177
- /**
3178
- * XtsPointCloudCurve3 — Three.js Curve3 wrapper around the Free-path sample.
3725
+ * One class for every `pathType`, because there was never more than one
3726
+ * implementation: `sampleModulePath` already dispatches Straight / Curve /
3727
+ * Free itself, so the two classes this replaces (`XtsArcCurve3` and
3728
+ * `XtsPointCloudCurve3`) were byte-for-byte identical apart from the
3729
+ * `pathType` string each rejected in its constructor.
3179
3730
  *
3180
- * The off-by-one clamp at s == ModuleLength is handled in
3181
- * pathMath.sampleModulePath.
3731
+ * Note: CurvePath delegates to subcurves length-proportionally, so
3732
+ * `getLength()` MUST return the actual arc length for them to be weighted
3733
+ * correctly. Our subcurves are arc-length parametrised (t = s / L), so this
3734
+ * just works.
3182
3735
  */
3183
3736
 
3184
- declare class XtsPointCloudCurve3 extends Curve<Vector3> {
3737
+ declare class XtsModuleCurve3 extends Curve<Vector3> {
3185
3738
  readonly module: ModuleCatalogEntry;
3186
3739
  constructor(module: ModuleCatalogEntry);
3187
3740
  getPoint(t: number, optionalTarget?: Vector3): Vector3;
@@ -3192,11 +3745,10 @@ declare class XtsPointCloudCurve3 extends Curve<Vector3> {
3192
3745
  /**
3193
3746
  * PathBuilder — assemble a `THREE.CurvePath<Vector3>` for a Part.
3194
3747
  *
3195
- * One CurvePath per part, chaining all module curves end-to-end.
3196
- * Per module:
3197
- * - Straight → THREE.LineCurve3 (module geometry baked in)
3198
- * - Curve → XtsArcCurve3 (analytic)
3199
- * - Free → XtsPointCloudCurve3
3748
+ * One CurvePath per part, chaining all module curves end-to-end. Every
3749
+ * module — Straight, Curve or Free alike — becomes an `XtsModuleCurve3`,
3750
+ * which defers to `sampleModulePath`; that function already dispatches on
3751
+ * `pathType`, so doing it a second time here bought nothing.
3200
3752
  *
3201
3753
  * Each subcurve here is expressed in **module-local coordinates** (origin at
3202
3754
  * module entry). The world transform of the module = chain.startWorldPose
@@ -3224,6 +3776,34 @@ declare function buildPartPath(modules: ReadonlyArray<ModuleEntry>): PartPath;
3224
3776
  */
3225
3777
  declare function poseToMatrix4(pose: PlanarPose, out?: Matrix4): Matrix4;
3226
3778
 
3779
+ /**
3780
+ * Pure helpers for composing per-XPU `trackTransform` and root
3781
+ * `XtsConfig.orientation` into Three.js Matrix4 instances. Lives in geometry/
3782
+ * so the component layer (XtsViewer3D, XtsScene) and the test suite share a
3783
+ * single source of truth — no duplicated math.
3784
+ *
3785
+ * The field defaults come from `resolvePose`, which `<PoseGroup>` also uses
3786
+ * to build the corresponding three.js `<group>`. That is what keeps the
3787
+ * matrix these functions return describing the same pose the renderer draws;
3788
+ * `pose.test.ts` asserts it directly.
3789
+ *
3790
+ * Composition order:
3791
+ *
3792
+ * world = orientation ⊗ trackTransform ⊗ partTransformation ⊗ chain
3793
+ *
3794
+ * `partTransformation` is applied inside the per-XPU group by XtsScene, so
3795
+ * the helpers here only cover the outermost two layers.
3796
+ */
3797
+
3798
+ /**
3799
+ * Default Z lift for a track that does not specify `positionMm`. The XTS
3800
+ * track sits on a base plate / table top in real installations, so 0 mm
3801
+ * (= floor) is rarely what the user wants. 200 mm matches the standard
3802
+ * Beckhoff table height used throughout the demo configs and renders
3803
+ * correctly under the default shadow-casting ground plane.
3804
+ */
3805
+ declare const DEFAULT_TRACK_Z_MM = 200;
3806
+
3227
3807
  /**
3228
3808
  * Point-cloud registry for Free-path modules.
3229
3809
  *
@@ -3328,6 +3908,67 @@ declare function interpolateHeatmapValue(samples: ReadonlyArray<HeatmapSample>,
3328
3908
  */
3329
3909
  declare function normaliseToHeatmapRange(value: number, min: number, max: number): number;
3330
3910
 
3911
+ /**
3912
+ * The vertical extent of a track module, in the path frame.
3913
+ *
3914
+ * `moduleCollision.ts` needs to know how tall a module is and where it sits
3915
+ * relative to the mover path. It used to assume one coarse box for every type —
3916
+ * `0 … DEFAULT_MODULE_HEIGHT_MM`, entirely *above* the path plane — which is
3917
+ * neither the right height nor the right side: a motor module measures about
3918
+ * 40 mm and straddles the plane, roughly half above and half below. Two tracks
3919
+ * stacked closer than 100 mm therefore reported an overlap that does not exist,
3920
+ * and the deepest ones reported it on every module pair.
3921
+ *
3922
+ * ## Why only Z, and not the width too
3923
+ *
3924
+ * The GLB sidecar carries `approximateBoundsMm`, the axis-aligned box of the
3925
+ * CAD body *before* `originCorrection` is applied. Composing the two gives the
3926
+ * body in the path frame — but only its **Z** range is a cross-section.
3927
+ *
3928
+ * Module paths are planar in XY and curves bend about Z, so a curved module's
3929
+ * GLB spans its whole arc in X and Y: the 180° AT2050_0500 measures 307 mm
3930
+ * across in Y, which is the sweep of the U-turn and not how wide the beam is.
3931
+ * Its Z range, on the other hand, is the same 40.1 mm as the straight it is
3932
+ * built from, because the sweep never leaves the plane.
3933
+ *
3934
+ * So the height is read from the asset and the half-width stays the coarse
3935
+ * constant. Taking the width from the same box would inflate every curve
3936
+ * segment to the size of its arc and report a collision for every oval.
3937
+ *
3938
+ * Overrides are not consulted: `SidecarOverrides` can replace a module's
3939
+ * `originCorrection` but carries no bounds, so there is nothing to override.
3940
+ */
3941
+
3942
+ /** Where a module's body sits along Z, relative to the mover path plane. */
3943
+ interface ModuleHeightSpan {
3944
+ /** Lowest point of the body [mm]; negative means below the path plane. */
3945
+ minZMm: number;
3946
+ /** Highest point of the body [mm]. */
3947
+ maxZMm: number;
3948
+ }
3949
+ /**
3950
+ * The span a module without usable bounds falls back to: the historic coarse
3951
+ * box, so a type the asset package does not describe behaves as it always did.
3952
+ */
3953
+ declare function coarseHeightSpan(heightMm: number): ModuleHeightSpan;
3954
+ /**
3955
+ * Compose `approximateBoundsMm` through `originCorrection` and keep the Z range.
3956
+ *
3957
+ * All eight corners are transformed rather than only the two extremes: every
3958
+ * shipped correction happens to be a multiple of 90°, which would keep the box
3959
+ * axis-aligned, but nothing in the sidecar format promises that, and under a
3960
+ * 45° correction two corners would describe a box far too small.
3961
+ */
3962
+ declare function heightSpanOf(sidecar: ModuleSidecar): ModuleHeightSpan | null;
3963
+ /**
3964
+ * How tall `moduleType` is and where it sits, or the coarse fallback.
3965
+ *
3966
+ * Resolved per module type rather than once per run: an Eco straight, a
3967
+ * hygienic module with its tower and an AT2002 with its infeed box are three
3968
+ * different heights, and a stack has to be judged against the one it holds.
3969
+ */
3970
+ declare function moduleHeightSpan(moduleType: ModuleType3D, fallbackHeightMm: number): ModuleHeightSpan;
3971
+
3331
3972
  /**
3332
3973
  * Shared closed-loop detection for built chains.
3333
3974
  *
@@ -3345,6 +3986,34 @@ declare function normaliseToHeatmapRange(value: number, min: number, max: number
3345
3986
  */
3346
3987
  declare function isChainClosed(chain: BuiltChain): boolean;
3347
3988
 
3989
+ /**
3990
+ * ARGB integers ↔ CSS hex.
3991
+ *
3992
+ * `StationConfig.stationColor` and `AreaConfig.color` arrive as 32-bit ARGB
3993
+ * ints, which is how the 2D canvas tooling upstream of this viewer stores
3994
+ * colour. three.js wants `#RRGGBB`.
3995
+ *
3996
+ * These lived inside `XtsStations.tsx` and were imported across to
3997
+ * `XtsAreas` and `XtsStopPositionMovers` — a bit-twiddling function reached
3998
+ * through a component. They were also not exported from the package, so the
3999
+ * playground wrote its own copy of the same shifts to render the colour
4000
+ * pickers.
4001
+ */
4002
+ /**
4003
+ * `0xAARRGGBB` → `'#RRGGBB'`.
4004
+ *
4005
+ * Alpha is dropped: three.js handles opacity through
4006
+ * `Material.transparent` / `.opacity`, separately from base colour.
4007
+ */
4008
+ declare function argbIntToHex(argb: number): string;
4009
+ /**
4010
+ * `'#RRGGBB'` → `0xFFRRGGBB`, the inverse of {@link argbIntToHex}.
4011
+ *
4012
+ * Alpha is set to fully opaque, since the hex form carries none. A string
4013
+ * that is not a six-digit hex colour yields opaque black rather than `NaN`.
4014
+ */
4015
+ declare function hexToArgbInt(hex: string): number;
4016
+
3348
4017
  /**
3349
4018
  * Feed segments (Einspeisestränge).
3350
4019
  *
@@ -3435,8 +4104,126 @@ declare function collectFeedSegmentsForXpu(xpu: ProcessingUnitConfig, chainsByPa
3435
4104
  * (rendering, imperative API, bounding-box) sees the corrected values.
3436
4105
  */
3437
4106
 
4107
+ /**
4108
+ * The Y / Z-Euler flip on a single track transform.
4109
+ *
4110
+ * The flip is its own inverse, so this is also how a value the viewer
4111
+ * computed in its own frame — a pose dragged with the transform gizmo — is
4112
+ * handed back in the convention the host states its transforms in.
4113
+ */
4114
+ declare function applyBeckhoffXtsConventionToTrackTransform(tt: TrackTransform): TrackTransform;
4115
+ /**
4116
+ * The same Y / Z-Euler flip, applied to the imperative
4117
+ * `trackTransformOverrides` map.
4118
+ *
4119
+ * `effectiveTrackTransform` returns an override *instead of* the config
4120
+ * value, so an override that skipped this pass would be interpreted in the
4121
+ * opposite Y convention from the config entry it replaces — the track would
4122
+ * jump to the mirrored side the moment a caller touched it. Both inputs have
4123
+ * to cross the convention boundary together.
4124
+ */
4125
+ declare function applyBeckhoffXtsConventionToOverrides(overrides: Record<number, TrackTransform> | undefined): Record<number, TrackTransform> | undefined;
3438
4126
  declare function applyBeckhoffXtsConvention(config: NormalizedXtsConfig): NormalizedXtsConfig;
3439
4127
 
4128
+ /**
4129
+ * HepcoGfxLayout — where the Hepco GFX guideway's repeating features sit along
4130
+ * a Part's chained path.
4131
+ *
4132
+ * Everything here is pure arc-length arithmetic: spans are `[startMm, endMm]`
4133
+ * measured from the start of the chain, and holes are `{ atMm, lateralMm }`.
4134
+ * <HepcoGfxRail> turns those into curve parameters and transforms. Keeping the
4135
+ * layout out of the component is what makes the pattern testable without a
4136
+ * renderer, the same split `moverInstancing.ts` uses for the mover batch.
4137
+ *
4138
+ * ## Where the patterns come from
4139
+ *
4140
+ * Measured off `stepfiles/hepco-gfx-rail/BA-GFX-1TL-3M-B.stp`. In that assembly
4141
+ * the baseplate's module grid runs at 250 mm, and every feature keys off it:
4142
+ *
4143
+ * - Central plate slots: 104.78 mm long, centred ON the module boundaries.
4144
+ * - Plate M8 bores: Ø8.5, 62.5 mm pitch (= 250/4) from a module boundary,
4145
+ * in rows at 30 and 80 mm either side of the plate's centreline.
4146
+ * - Bracket lightening bores: Ø18, 125 mm pitch, on the 30 mm row so they sit
4147
+ * directly over every second M8 bore.
4148
+ * - Bracket wall windows: one 40.23 mm window and two 5.96 mm slots per
4149
+ * 250 mm, at the offsets in `WALL_WINDOWS_MM`.
4150
+ *
4151
+ * The brackets themselves come as `GFX-UMB-1RH` (240.30 mm) and `-2LH`
4152
+ * (490.30 mm) parts — i.e. 250·n − 9.70. Consecutive parts butt exactly on a
4153
+ * module boundary; the 9.70 mm shortfall is taken once at each END of a run,
4154
+ * not as a gap at every joint, and a `GFX-UMB-EB` end block closes each end.
4155
+ *
4156
+ * ## Where a bracket can go
4157
+ *
4158
+ * Not everywhere. The bracket's inner face sits 114 mm inboard of the path, so
4159
+ * on a tight enough turn that face is swept on a radius near zero and the beam
4160
+ * is crushed into the centre of the arc, inside the very module it carries. See
4161
+ * `carriesBracket` for the criterion and the numbers behind it — the catalogue
4162
+ * splits cleanly, with the 22.5 and 45 degree curves nowhere near the limit and
4163
+ * only the AT2050 clothoids past it.
4164
+ *
4165
+ * The 250 mm grid is measured from the start of the chain rather than from each
4166
+ * module, so it stays continuous across the 233 mm and 249 mm short modules —
4167
+ * which is also what the real extruded bracket does, being stock cut to length.
4168
+ */
4169
+
4170
+ /** A run along the path, in mm from the start of the chain. */
4171
+ interface Span {
4172
+ readonly startMm: number;
4173
+ readonly endMm: number;
4174
+ }
4175
+ /** One bore, as a point on the path plus a lateral offset in the path frame. */
4176
+ interface BorePlacement {
4177
+ readonly atMm: number;
4178
+ /** Lateral offset from the rail centreline, before `stackYOffsetMm` (mm). */
4179
+ readonly lateralMm: number;
4180
+ }
4181
+ /**
4182
+ * The strips of plate that survive between the central slots — the inner-edge
4183
+ * band the slots would otherwise remove, put back between them.
4184
+ *
4185
+ * The slots are centred on the feature grid, so the first and last are cut off
4186
+ * by the ends of the chain; `invertSpans` clips them rather than dropping them,
4187
+ * which is what a plate cut to length actually looks like.
4188
+ */
4189
+ declare function hepcoGfxBaseplateBridgeSpans(chain: BuiltChain, dims: HepcoGfxRailProfileDims): Span[];
4190
+ /** A stretch of bracket, and whether each of its ends is a real end. */
4191
+ interface BracketRun extends Span {
4192
+ /** The run begins at an end of the bracket, not at a wrap-around seam. */
4193
+ readonly capStart: boolean;
4194
+ readonly capEnd: boolean;
4195
+ }
4196
+ /**
4197
+ * The stretches the bracket occupies: one per contiguous run of modules that
4198
+ * can carry one, each inset by `HEPCO_BRACKET_END_INSET_MM` at its capped ends.
4199
+ *
4200
+ * A closed chain that begins and ends mid-straight has one run split across the
4201
+ * seam. Those two halves are still extruded separately — the spline runs from 0
4202
+ * to L and cannot cross the seam — but neither is inset or capped there, so the
4203
+ * bracket reads as continuous through it.
4204
+ */
4205
+ declare function hepcoGfxBracketRuns(chain: BuiltChain, closed: boolean, dims: HepcoGfxRailProfileDims): BracketRun[];
4206
+ /**
4207
+ * The solid stretches of the bracket's upper wall — the complement of the
4208
+ * windows, clipped to the bracket run.
4209
+ */
4210
+ declare function hepcoGfxBracketWallSpans(run: Span): Span[];
4211
+ /**
4212
+ * The GFX-UMB-EB end blocks: one at each capped end of each run.
4213
+ *
4214
+ * They occupy the first and last 15 mm OF a run, not a further 15 mm beyond it:
4215
+ * in the CAD the run starts at X = -378.04 and the end block occupies
4216
+ * -378.04 to -363.04. The block spans the bracket's full 40 mm width where the
4217
+ * wall above it is only 15 mm, so what it does is close off the open end of the
4218
+ * wall — which is why it has to sit inside the run to be seen. A seam end on a
4219
+ * closed chain is not an end and gets none.
4220
+ */
4221
+ declare function hepcoGfxBracketEndBlockSpans(runs: readonly BracketRun[]): Span[];
4222
+ /** Every M8 clearance bore in the plate that falls on open plate. */
4223
+ declare function hepcoGfxPlateBores(chain: BuiltChain, dims: HepcoGfxRailProfileDims): BorePlacement[];
4224
+ /** Every lightening bore through the bracket's base blocks. */
4225
+ declare function hepcoGfxBracketBores(runs: readonly BracketRun[], dims: HepcoGfxRailProfileDims): BorePlacement[];
4226
+
3440
4227
  /**
3441
4228
  * Path-sample helpers — convert a chain + a [startMm, endMm] range into a
3442
4229
  * world-frame polyline of Vector3s. Used by Stations, Dimensions and
@@ -3482,9 +4269,15 @@ declare function sampleChainPoint(chain: BuiltChain, positionMm: number, liftZ?:
3482
4269
  * - Multi → ctrl/meta-click adds, shift-click range-selects (modules
3483
4270
  * within the same part only)
3484
4271
  *
3485
- * Range-select is module-only. For movers, shift-click behaves as a
3486
- * plain replacing click — there is no natural "range" along the mover
3487
- * list. Use ctrl/meta for additive mover selection.
4272
+ * Range-select is module-only. For movers, tracks and static custom assets,
4273
+ * shift-click behaves as a plain replacing click — there is no natural
4274
+ * "range" along any of those lists. Use ctrl/meta for additive selection.
4275
+ *
4276
+ * Selecting a whole track (`applyTrackClick`, what `selectionScope: 'track'`
4277
+ * dispatches to) selects everything on it: `isModuleSelected` and
4278
+ * `isMoverSelected` answer `true` for its contents without those entries
4279
+ * being listed one by one, so the track tints as one object and a host
4280
+ * asking "is this module selected" gets the answer it means.
3488
4281
  */
3489
4282
 
3490
4283
  type ClickModifiers = {
@@ -3493,11 +4286,24 @@ type ClickModifiers = {
3493
4286
  meta?: boolean;
3494
4287
  };
3495
4288
  declare const EMPTY_SELECTION: SelectionState;
4289
+ declare function isTrackSelected(selection: SelectionState, ref: TrackRef): boolean;
3496
4290
  declare function isModuleSelected(selection: SelectionState, ref: ModuleRef): boolean;
3497
4291
  declare function isMoverSelected(selection: SelectionState, ref: MoverRef): boolean;
4292
+ declare function isCustomAssetSelected(selection: SelectionState, ref: CustomAssetRef): boolean;
3498
4293
  declare function deselectAll(): SelectionState;
3499
4294
  declare function applyModuleClick(selection: SelectionState, ref: ModuleRef, mode: SelectionMode, modifiers?: ClickModifiers): SelectionState;
3500
4295
  declare function applyMoverClick(selection: SelectionState, ref: MoverRef, mode: SelectionMode, modifiers?: ClickModifiers): SelectionState;
4296
+ /**
4297
+ * A click anywhere on a track, under `selectionScope: 'track'`. Whichever
4298
+ * module or mover was hit, what ends up selected is the processing unit —
4299
+ * the thing that actually has a pose to edit.
4300
+ */
4301
+ declare function applyTrackClick(selection: SelectionState, ref: TrackRef, mode: SelectionMode, modifiers?: ClickModifiers): SelectionState;
4302
+ /**
4303
+ * A click on a static custom asset. Same modes as the others, minus
4304
+ * range-select: custom assets have no order to range over.
4305
+ */
4306
+ declare function applyCustomAssetClick(selection: SelectionState, ref: CustomAssetRef, mode: SelectionMode, modifiers?: ClickModifiers): SelectionState;
3501
4307
  declare function applyEmptyClick(selection: SelectionState, mode: SelectionMode): SelectionState;
3502
4308
 
3503
4309
  /**
@@ -3530,24 +4336,70 @@ interface Props {
3530
4336
  declare const ModuleCornerMarkers: React__default.FC<Props>;
3531
4337
 
3532
4338
  /**
3533
- * <DimensionLabel> — camera-facing text used by the Dimensions overlay
3534
- * (and re-usable elsewhere). Wraps drei's `<Billboard>` + `<Text>` so the
3535
- * label always reads horizontally regardless of camera orbit, and is
3536
- * placed at a small +Z offset above the dimension tick so the text and
3537
- * the marker don't z-fight.
4339
+ * Default axis palette — the red / green / blue triple used by the common
4340
+ * viewport gizmos (three-viewport-gizmo, Blender, Fusion). Bright enough to
4341
+ * stay readable on both the light and the dark canvas backgrounds.
3538
4342
  */
4343
+ declare const GIZMO_AXIS_COLORS: readonly [string, string, string];
4344
+ /** Default handle letters, positive axes first. */
4345
+ declare const GIZMO_AXIS_LABELS: readonly [string, string, string];
3539
4346
 
3540
4347
  /**
3541
- * Default 3D-text font: Roboto Condensed (Latin, regular weight) served
3542
- * from jsDelivr's @fontsource mirror. Pinned to a fixed major version so
3543
- * the stack stays reproducible; the URL responds with a long
3544
- * Cache-Control TTL so repeated reads come from the browser cache.
4348
+ * labelFont — resolution of the font URL every 3D text label is drawn
4349
+ * with, and the reason none of them can stall the scene any more.
4350
+ *
4351
+ * troika-three-text loads its font over XHR and, when that request
4352
+ * fails, only logs `Failure loading font <url>`: the internal callback
4353
+ * queue for that URL is never drained. drei's `<Text>` wraps exactly
4354
+ * that callback in `suspend(...)`, so a dangling load becomes a promise
4355
+ * that never settles. With one `<Suspense fallback={null}>` around the
4356
+ * scene, a single unreachable font blanked the whole viewer — geometry,
4357
+ * grid, ViewCube and all — with nothing in the console beyond troika's
4358
+ * own line. Every sample that carried a station or an area label went
4359
+ * dark that way.
4360
+ *
4361
+ * Two things prevent that here:
4362
+ *
4363
+ * 1. The default font is compiled in (`labelFontData.ts`) and handed to
4364
+ * troika as a `blob:` URL, so the common path never touches the
4365
+ * network and cannot fail.
4366
+ * 2. A caller-supplied font URL is HEAD/GET-probed before it is handed
4367
+ * over. Until it answers, labels render in the built-in font; if it
4368
+ * never answers, they keep rendering in the built-in font and the
4369
+ * failure is reported once, loudly. troika is never given a URL that
4370
+ * has not already been fetched successfully.
4371
+ *
4372
+ * `<DimensionLabel>` / `<MoverIdLabel>` additionally wrap their `<Text>`
4373
+ * in a local Suspense boundary, so even a font that parses badly costs
4374
+ * one label rather than the scene.
4375
+ */
4376
+ /**
4377
+ * Default 3D-text font: Roboto Condensed (Latin, regular weight).
4378
+ *
4379
+ * The value is the jsDelivr `@fontsource` mirror it was originally
4380
+ * fetched from, kept stable because it is part of the public API. It is
4381
+ * no longer fetched: the viewer ships the same WOFF compiled in, and
4382
+ * treats this URL as "use the built-in copy", so passing it explicitly
4383
+ * is equivalent to passing nothing and costs no request.
3545
4384
  *
3546
- * `font` on troika-three-text expects an OTF / TTF / WOFF URL — NOT a CSS
3547
- * font-family name. Consumers can override via `<DimensionLabel
3548
- * fontFamily="https://…/whatever.woff" />` to swap in a brand font.
4385
+ * `font` on troika-three-text expects an OTF / TTF / WOFF URL — NOT a
4386
+ * CSS font-family name. Any other URL is used as given, once a probe
4387
+ * has confirmed it actually loads.
3549
4388
  */
3550
4389
  declare const DEFAULT_LABEL_FONT_URL: "https://cdn.jsdelivr.net/npm/@fontsource/roboto-condensed@5.2.7/files/roboto-condensed-latin-400-normal.woff";
4390
+ /**
4391
+ * URL of the compiled-in Roboto Condensed WOFF, as a `blob:` object URL
4392
+ * created on first use and cached for the lifetime of the document.
4393
+ *
4394
+ * A blob URL rather than a `data:` URL on purpose: troika loads fonts
4395
+ * through `XMLHttpRequest` and discards any response whose `status` is
4396
+ * 0, which is what several engines report for non-HTTP schemes. Blob
4397
+ * URLs answer with a regular 200.
4398
+ *
4399
+ * Falls back to a `data:` URL where `URL.createObjectURL` is missing
4400
+ * (jsdom, some SSR shims) so the value is always a usable string.
4401
+ */
4402
+ declare function bundledLabelFontUrl(): string;
3551
4403
 
3552
4404
  /**
3553
4405
  * <MoverIdLabel> — Camera-facing 3D text label that shows a mover's
@@ -3558,7 +4410,7 @@ declare const DEFAULT_LABEL_FONT_URL: "https://cdn.jsdelivr.net/npm/@fontsource/
3558
4410
  * the hood) so the label stays readable as the camera orbits. All
3559
4411
  * styling is driven by `MoverIdLabelOptions`; the component supplies
3560
4412
  * the documented defaults (15 mm black text, 55 mm above the mover's
3561
- * magnet-plate origin, Roboto Condensed via jsDelivr).
4413
+ * magnet-plate origin, the viewer's built-in Roboto Condensed).
3562
4414
  */
3563
4415
 
3564
4416
  interface MoverIdLabelProps {
@@ -3579,9 +4431,10 @@ declare const MoverIdLabel: React__default.FC<MoverIdLabelProps>;
3579
4431
  * so the viewer renders correctly without a matching CDN mirror.
3580
4432
  *
3581
4433
  * Module count: 27
3582
- * Mover count: 13
4434
+ * Mover count: 12
3583
4435
  * Tool count: 2
3584
- * Rail count: 8
4436
+ * Plate count: 7
4437
+ * Rail count: 11
3585
4438
  */
3586
4439
 
3587
4440
  /**
@@ -3635,8 +4488,8 @@ declare function useSidecarSource(): SidecarSourceConfig;
3635
4488
  * only set `Beckhoff`; the loader silently falls back to that variant when
3636
4489
  * `HepcoGfx` is requested but unavailable.
3637
4490
  *
3638
- * `null` for `Beckhoff` marks "prepared but not delivered"
3639
- * (AT2042 / AT2140) — the loader renders a placeholder AABB mesh.
4491
+ * A `null` `Beckhoff` entry marks a module type that is catalogued but has
4492
+ * no GLB — the loader renders a placeholder AABB mesh instead.
3640
4493
  */
3641
4494
 
3642
4495
  /** Filename mapping per ModuleType3D (relative to assetsBaseUrl). */
@@ -3662,7 +4515,7 @@ declare const DEFAULT_RAIL_MANIFEST: Readonly<Record<RailType3D, {
3662
4515
  * - Straights pick the matching-length AT9000 rail (0249 / 0250 / 0500).
3663
4516
  * - +22.5° curves (AT2020 / AT2021) use AT9020.
3664
4517
  * - −22.5° curves (AT2025 / AT2026) use AT9025.
3665
- * - 45° curves (AT2040 / AT2041 / AT2042 / AT2140) use AT9040.
4518
+ * - 45° curves (AT2040 / AT2041) use AT9040.
3666
4519
  * - The 180° clothoid pair (AT2050 0500/0501 + merged 0500_180) uses
3667
4520
  * AT9050, whose GLB already covers the full 500 mm path.
3668
4521
  * - Hygienic ATH modules already include the rail in their module GLB
@@ -3672,12 +4525,15 @@ declare const MODULE_GUIDING_RAIL_MAP: Readonly<Partial<Record<ModuleType3D, Rai
3672
4525
  /** Resolve a module's GLB URL with manifest override + rail-system fallback. */
3673
4526
  declare function resolveModuleGlbUrl(moduleType: ModuleType3D, rail: RailSystem, override?: AssetManifest['modules']): string | null;
3674
4527
  declare function resolveMoverGlbUrl(moverType: MoverType3D, override?: AssetManifest['movers']): string;
4528
+ declare function resolvePlateGlbUrl(plateType: MagnetPlateType3D, override?: AssetManifest['magnetPlates']): string;
3675
4529
  declare function resolveToolGlbUrl(toolType: MoverToolType3D, override?: AssetManifest['tools']): string;
3676
4530
  /**
3677
- * Pick the guiding-rail RailType for a module, or `null` if no separate
3678
- * rail mesh should render (ATH variants, undeclared module types).
4531
+ * Which guiding rail a module gets, given the mover running on it.
4532
+ *
4533
+ * `moverType` is optional so existing callers keep the -0055 default; pass it
4534
+ * to get the variant Beckhoff actually pairs with that mover.
3679
4535
  */
3680
- declare function resolveGuidingRailType(moduleType: ModuleType3D): RailType3D | null;
4536
+ declare function resolveGuidingRailType(moduleType: ModuleType3D, moverType?: MoverType3D): RailType3D | null;
3681
4537
  /**
3682
4538
  * Resolve the GLB filename for a guiding-rail type, honoring user-supplied
3683
4539
  * manifest overrides. Returns empty string when the rail is unknown.
@@ -3774,9 +4630,9 @@ declare function getSharedAssetLoader(): AssetLoader;
3774
4630
  declare function composeAssetUrl(baseUrl: string, filename: string): string;
3775
4631
 
3776
4632
  /**
3777
- * The four sidecar kinds as data.
4633
+ * The five sidecar kinds as data.
3778
4634
  *
3779
- * Modules, movers, tools and rails all resolve their calibration the same
4635
+ * Modules, movers, tools, magnet plates and rails all resolve the same
3780
4636
  * way — compiled-in table, else `<assetsBaseUrl>/<filename>`, else identity.
3781
4637
  * Only two things differ per kind: which built-in table to read and how the
3782
4638
  * filename is spelled. Keeping those two as a table (rather than four copies
@@ -3803,6 +4659,10 @@ interface SidecarKinds {
3803
4659
  id: MoverToolType3D;
3804
4660
  sidecar: MoverToolSidecar;
3805
4661
  };
4662
+ plate: {
4663
+ id: MagnetPlateType3D;
4664
+ sidecar: MagnetPlateSidecar;
4665
+ };
3806
4666
  rail: {
3807
4667
  id: RailType3D;
3808
4668
  sidecar: RailSidecar;
@@ -3922,4 +4782,4 @@ declare function snapToGrid(positionMm: Vec3, gridMm: number): Vec3;
3922
4782
  /** Convenience: `resolveSceneContext` + `toEntityRef`. */
3923
4783
  declare function resolveEntityFromObject(object: Object3D | null, instanceId?: number): MeasuredEntityRef | null;
3924
4784
 
3925
- export { type AngleMeasurement, type Annotation, type AnnotationAnchor, type AnnotationInput, type AreaConfig, type AreaOptions, AssetLoader, type AssetManifest, BUILTIN_MODULE_SIDECARS, BUILTIN_MOVER_SIDECARS, BUILTIN_RAIL_SIDECARS, BUILTIN_TOOL_SIDECARS, type BuiltChain, type BuiltModule, type CameraProjection, type CameraState, type CaptureDimensions, type CaptureMode, type CaptureRequest, type CheckModuleCollisionsOptions, type CheckMoverCollisionsOptions, type ClickModifiers, type CoordinateSystem, type CustomAssetBinding, type CustomAssetConfig, type CustomMoverLayout, DEFAULT_LABEL_FONT_URL, DEFAULT_MEASUREMENT_STYLE, DEFAULT_MODULE_HALF_WIDTH_MM, DEFAULT_MODULE_HEIGHT_MM, DEFAULT_MODULE_MANIFEST, DEFAULT_MOVER_MANIFEST, DEFAULT_ORIGIN_CORRECTION, DEFAULT_RAIL_MANIFEST, DEFAULT_SNAP_KINDS, DEFAULT_SNAP_RADIUS_PX, DEFAULT_TOOL_MANIFEST, type DimensionOptions, type DisplayOptions, type DistanceMeasurement, EMPTY_SELECTION, type EndDelta, type FeedSegment, type FeedSegmentHighlight, type FeedSegmentSelector, type FeedSegmentSpan, type FocusOptions, type FocusTarget, type FrameCaptureOptions, type FrameCaptureSession, HEPCO_GFX_PROFILE, type HeatmapSample, type HepcoGfxRailProfileDims, IDENTITY_POSE, INFEED_MODULE_TYPES, type InfoBarConfig, type InfoBarMarkerConfig, type InfoBarOptions, type InteractionLocks, JSDELIVR_ASSETS_BASE_URL, MODULE_CATALOG, MODULE_GUIDING_RAIL_MAP, MOVER_CATALOG, type MarkerOptions, type MarkerShape, type MeasuredEntityRef, type Measurement, type MeasurementApi, type MeasurementAxis, type MeasurementDocument, type MeasurementDraft, type MeasurementFormatOptions, type MeasurementId, type MeasurementInput, type MeasurementKind, type MeasurementOptions, type MeasurementPoint, type MeasurementResult, type MeasurementSnapshot, type MeasurementStyle, type MeasurementTool, type ModuleAssetEntry, type ModuleCatalogEntry, type ModuleCollision, type ModuleCollisionPartInput, type ModuleCollisionXpuInput, ModuleCornerMarkers, type ModuleEntry, type ModuleHighlight, type ModuleProbe, type ModuleRef, type ModuleSidecar, type ModuleStatusEntry, type ModuleType3D, type MoverAssetEntry, type MoverCatalogEntry, type MoverCollision, type MoverConfig, MoverIdLabel, type MoverIdLabelOptions, type MoverPositionEntry, type MoverProbe, type MoverRef, type MoverSidecar, type MoverToolAssetEntry, type MoverToolConfig, type MoverToolSidecar, type MoverToolType3D, type MoverType3D, type NormalizationWarning, type NormalizedXtsConfig, type Orientation, type OriginCorrection, type PartConfig, type PartPath, type PartTransformation, type PathMeasurement, type PathSample, type PathType, type PerformanceOptions, type PlanarPose, type PointCloud, type PointProjector, type PositionFrame, type ProcessingUnitConfig, type RailAssetEntry, type RailSidecar, type RailSystem, type RailType3D, type ResolvedMeasurementStyle, SIDECAR_KINDS, type ScreenshotFormat, type ScreenshotMode, type ScreenshotOptions, type ScreenshotResult, type SelectionMode, type SelectionState, type SidecarIdOf, type SidecarKind, type SidecarKindSpec, type SidecarKinds, SidecarLoader, type SidecarOf, type SidecarOverrides, type SidecarSourceConfig, SidecarSourceContext, type SnapCandidate, type SnapKind, type SnapOptions, type SplitFeedSegmentsOptions, type StationConfig, type StationOptions, type StatorHeatmap, type StopPositionMoverOptions, type TextOptions, type TrackAnchor, type TrackDistanceMeasurement, type TrackIndex, type TrackIndexEntry, type TrackMetrics, type TrackMetricsResolver, type TrackProjection, type TrackTransform, VERSION, type Vec2, type Vec3, type ViewCubeAlignment, type ViewCubeOptions, XtsArcCurve3, type XtsConfig, XtsConfigNormalizer, type XtsModelDocument, XtsPointCloudCurve3, type XtsRuntimeState, XtsViewer3D, type XtsViewer3DProps, type XtsViewer3DRef, type XtsViewerError, type XtsViewerErrorCode, XtsViewerErrorException, angleDeg, applyBeckhoffXtsConvention, applyEmptyClick, applyModuleClick, applyMoverClick, arcPoints, axisDistanceMm, buildChain, buildModuleProbes, buildPartPath, buildTrackIndex, chainToUserPositionMm, checkAllModuleCollisions, checkAllMoverCollisions, checkModuleCollisions, checkSamePathCollisions, chooseSnapCandidate, collectFeedSegments, collectFeedSegmentsForXpu, collectTriangleSnapCandidates, composeAssetUrl, computeMeasurementResult, createHepcoGfxBaseplateShape, createHepcoGfxRailShape, deselectAll, distanceMm, findModuleAt, formatAngleDeg, formatLengthMm, getModuleEntry, getMoverEntry, getPointCloud, getSharedAssetLoader, interpolateHeatmapValue, isChainClosed, isKnownModuleType, isKnownMoverType, isModuleSelected, isMoverSelected, labelAnchorFor, moduleAnchorWorld, moduleEndDelta, moduleSidecarFilename, moverAnchorWorld, moverSidecarFilename, moverWorldAt, nearestTrackPoint, normaliseToHeatmapRange, normalizeXtsConfig, pathLengthsMm, pickGlbForRail, poseToMatrix4, railSidecarFilename, registerPointCloud, resolveEntityFromObject, resolveGuidingRailGlbUrl, resolveGuidingRailType, resolveHepcoGfxProfile, resolveMeasurementStyle, resolveModuleGlbUrl, resolveMoverGlbUrl, resolveOriginCorrection, resolveStopPositionMm, resolveToolGlbUrl, sampleChainPoint, sampleChainRange, sampleModulePath, snapToGrid, sortHeatmapSamples, splitIntoFeedSegments, toModuleCollisionInputs, toolSidecarFilename, trackAnchorWorld, trackDistanceMm, trackLengthOf, trackPolylineWorld, unregisterPointCloud, useSidecarSource, userToChainPositionMm, worldPointOnPart };
4785
+ export { type AngleMeasurement, type Annotation, type AnnotationAnchor, type AnnotationInput, type AreaConfig, type AreaOptions, AssetLoader, type AssetManifest, BUILTIN_MODULE_SIDECARS, BUILTIN_MOVER_SIDECARS, BUILTIN_RAIL_SIDECARS, BUILTIN_TOOL_SIDECARS, type BorePlacement, type BracketRun, type BuiltChain, type BuiltModule, type CalibrationMarkerOptions, type CameraProjection, type CameraState, type CaptureDimensions, type CaptureMode, type CaptureRequest, type CheckModuleCollisionsOptions, type CheckMoverCollisionsOptions, type ClickModifiers, type CollisionOptions, type CoordinateSystem, type CustomAssetBinding, type CustomAssetConfig, type CustomAssetRef, type CustomMoverLayout, DEFAULT_LABEL_FONT_URL, DEFAULT_MAGNET_PLATE_TYPE, DEFAULT_MEASUREMENT_STYLE, DEFAULT_MODULE_HALF_WIDTH_MM, DEFAULT_MODULE_HEIGHT_MM, DEFAULT_MODULE_MANIFEST, DEFAULT_MOVER_MANIFEST, DEFAULT_ORIGIN_CORRECTION, DEFAULT_RAIL_MANIFEST, DEFAULT_SNAP_KINDS, DEFAULT_SNAP_RADIUS_PX, DEFAULT_TOOL_MANIFEST, DEFAULT_TRACK_Z_MM, type DimensionOptions, type DisplayOptions, type DistanceMeasurement, EMPTY_SELECTION, type EndDelta, type FeedSegment, type FeedSegmentHighlight, type FeedSegmentSelector, type FeedSegmentSpan, type FocusOptions, type FocusTarget, type FrameCaptureOptions, type FrameCaptureSession, GIZMO_AXIS_COLORS, GIZMO_AXIS_LABELS, HEPCO_GFX_PROFILE, type HeatmapSample, type HepcoGfxRailProfileDims, IDENTITY_POSE, INFEED_MODULE_TYPES, type InfoBarConfig, type InfoBarMarkerConfig, type InfoBarOptions, type InteractionLocks, JSDELIVR_ASSETS_BASE_URL, type KeyboardNavigationOptions, type LightingOptions, MAGNET_PLATE_CATALOG, MODULE_CATALOG, MODULE_GUIDING_RAIL_MAP, MOVER_CATALOG, MOVER_INTEGRATED_PLATE, type MagnetPlateAssetEntry, type MagnetPlateCatalogEntry, type MagnetPlateType3D, type MarkerOptions, type MarkerShape, type MeasuredEntityRef, type Measurement, type MeasurementApi, type MeasurementAxis, type MeasurementDocument, type MeasurementDraft, type MeasurementFormatOptions, type MeasurementId, type MeasurementInput, type MeasurementKind, type MeasurementOptions, type MeasurementPatch, type MeasurementPoint, type MeasurementResult, type MeasurementSnapshot, type MeasurementStyle, type MeasurementTool, type ModuleAssetEntry, type ModuleCatalogEntry, type ModuleCollision, type ModuleCollisionOptions, type ModuleCollisionPartInput, type ModuleCollisionXpuInput, ModuleCornerMarkers, type ModuleEntry, type ModuleHeightSpan, type ModuleHighlight, type ModuleProbe, type ModuleRef, type ModuleSidecar, type ModuleStatusEntry, type ModuleType3D, type MoverAssetEntry, type MoverCatalogEntry, type MoverCollision, type MoverCollisionOptions, type MoverConfig, MoverIdLabel, type MoverIdLabelOptions, type MoverPositionEntry, type MoverProbe, type MoverRef, type MoverSidecar, type MoverToolAssetEntry, type MoverToolConfig, type MoverToolSidecar, type MoverToolStateEntry, type MoverToolType3D, type MoverType3D, type NormalizationWarning, type NormalizedXtsConfig, type Orientation, type OriginCorrection, type PartConfig, type PartPath, type PartTransformation, type PathMeasurement, type PathSample, type PathType, type PerformanceOptions, type PlanarPose, type PointCloud, type PointProjector, type PositionFrame, type PostProcessingOptions, type ProcessingUnitConfig, type ProfilePoint, type RailAssetEntry, type RailSidecar, type RailSystem, type RailType3D, type ResolvedMeasurementStyle, SIDECAR_KINDS, type ScreenshotFormat, type ScreenshotMode, type ScreenshotOptions, type ScreenshotResult, type SelectionColors, type SelectionMode, type SelectionScope, type SelectionState, type SidecarIdOf, type SidecarKind, type SidecarKindSpec, type SidecarKinds, SidecarLoader, type SidecarOf, type SidecarOverrides, type SidecarSourceConfig, SidecarSourceContext, type SnapCandidate, type SnapKind, type SnapOptions, type Span, type SplitFeedSegmentsOptions, type StationConfig, type StationOptions, type StatorHeatmap, type StopPositionMoverOptions, type TextOptions, type TrackAnchor, type TrackDistanceMeasurement, type TrackIndex, type TrackIndexEntry, type TrackMetrics, type TrackMetricsResolver, type TrackProjection, type TrackRef, type TrackTransform, type TransformGizmoChange, type TransformGizmoMode, type TransformGizmoOptions, type TransformGizmoPose, type TransformGizmoTarget, VERSION, type Vec2, type Vec3, type ViewCubeAlignment, type ViewCubeOptions, XtsModuleCurve3 as XtsArcCurve3, type XtsConfig, XtsConfigNormalizer, type XtsModelDocument, XtsModuleCurve3, XtsModuleCurve3 as XtsPointCloudCurve3, type XtsRuntimeState, XtsViewer3D, type XtsViewer3DProps, type XtsViewer3DRef, type XtsViewerError, type XtsViewerErrorCode, XtsViewerErrorException, angleDeg, applyBeckhoffXtsConvention, applyBeckhoffXtsConventionToOverrides, applyBeckhoffXtsConventionToTrackTransform, applyCustomAssetClick, applyEmptyClick, applyModuleClick, applyMoverClick, applyTrackClick, arcPoints, argbIntToHex, axisDistanceMm, buildChain, buildHepcoGfxBaseplateBridgeOutline, buildHepcoGfxBaseplateOutline, buildHepcoGfxBracketBaseOutline, buildHepcoGfxBracketEndBlockOutline, buildHepcoGfxBracketOutline, buildHepcoGfxBracketWallOutline, buildHepcoGfxRailOutline, buildModuleProbes, buildPartPath, buildTrackIndex, bundledLabelFontUrl, chainToUserPositionMm, checkAllModuleCollisions, checkAllMoverCollisions, checkModuleCollisions, checkSamePathCollisions, chooseSnapCandidate, coarseHeightSpan, collectFeedSegments, collectFeedSegmentsForXpu, collectTriangleSnapCandidates, composeAssetUrl, computeMeasurementResult, createHepcoGfxBaseplateBridgeShape, createHepcoGfxBaseplateShape, createHepcoGfxBracketBaseShape, createHepcoGfxBracketEndBlockShape, createHepcoGfxBracketShape, createHepcoGfxBracketWallShape, createHepcoGfxRailShape, deselectAll, distanceMm, findModuleAt, formatAngleDeg, formatLengthMm, getMagnetPlateEntry, getModuleEntry, getMoverEntry, getPointCloud, getSharedAssetLoader, heightSpanOf, hepcoGfxBaseplateBridgeSpans, hepcoGfxBracketBores, hepcoGfxBracketEndBlockSpans, hepcoGfxBracketRuns, hepcoGfxBracketWallSpans, hepcoGfxPlateBores, hexToArgbInt, interpolateHeatmapValue, isChainClosed, isCustomAssetSelected, isKnownMagnetPlateType, isKnownModuleType, isKnownMoverType, isModuleSelected, isMoverSelected, isTrackSelected, labelAnchorFor, moduleAnchorWorld, moduleEndDelta, moduleHeightSpan, moduleSidecarFilename, moverAnchorWorld, moverSidecarFilename, moverWorldAt, nearestTrackPoint, normaliseToHeatmapRange, normalizeXtsConfig, pathLengthsMm, pickGlbForRail, poseToMatrix4, railSidecarFilename, registerPointCloud, resolveAttachedPlateType, resolveEntityFromObject, resolveGuidingRailGlbUrl, resolveGuidingRailType, resolveHepcoGfxProfile, resolveMeasurementStyle, resolveModuleGlbUrl, resolveMoverGlbUrl, resolveOriginCorrection, resolvePlateGlbUrl, resolveStopPositionMm, resolveToolGlbUrl, sampleChainPoint, sampleChainRange, sampleModulePath, snapToGrid, sortHeatmapSamples, splitIntoFeedSegments, toModuleCollisionInputs, toolSidecarFilename, trackAnchorWorld, trackDistanceMm, trackLengthOf, trackPolylineWorld, unregisterPointCloud, useSidecarSource, userToChainPositionMm, worldPointOnPart };