@2112-lab/central-plant 0.3.28 → 0.3.30

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.
@@ -0,0 +1,648 @@
1
+ import { inherits as _inherits, createClass as _createClass, createForOfIteratorHelper as _createForOfIteratorHelper, slicedToArray as _slicedToArray, toConsumableArray as _toConsumableArray, superPropGet as _superPropGet, classCallCheck as _classCallCheck, callSuper as _callSuper } from '../../../_virtual/_rollupPluginBabelHelpers.js';
2
+ import * as THREE from 'three';
3
+ import { BaseDisposable } from '../../core/baseDisposable.js';
4
+
5
+ var IoBehaviorManager = /*#__PURE__*/function (_BaseDisposable) {
6
+ function IoBehaviorManager(sceneViewer) {
7
+ var _this;
8
+ _classCallCheck(this, IoBehaviorManager);
9
+ _this = _callSuper(this, IoBehaviorManager);
10
+ _this.sceneViewer = sceneViewer;
11
+
12
+ /**
13
+ * Map: `${parentUuid}::${attachmentId}` → Array<{
14
+ * anim: Object,
15
+ * mesh: THREE.Object3D,
16
+ * origPos: THREE.Vector3,
17
+ * origRot: THREE.Euler
18
+ * }>
19
+ */
20
+ _this._entries = new Map();
21
+ return _this;
22
+ }
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────
25
+ // PUBLIC API
26
+ // ─────────────────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Register animation entries for one attached I/O device.
30
+ * Should be called after the device GLB has been merged into the scene
31
+ * so that mesh references are live.
32
+ *
33
+ * @param {string} attachmentId - The attachment key (e.g. 'attch-switch-01')
34
+ * @param {Object|Array} behaviorConfig - Serialized config; either the full object
35
+ * { behaviors: [...] } or a plain array of behavior entries.
36
+ * @param {THREE.Object3D} deviceModelRoot - The device's root Object3D as added to the scene
37
+ * @param {string} parentUuid - UUID of the host component Object3D
38
+ */
39
+ _inherits(IoBehaviorManager, _BaseDisposable);
40
+ return _createClass(IoBehaviorManager, [{
41
+ key: "loadBehaviors",
42
+ value: function loadBehaviors(attachmentId, behaviorConfig, deviceModelRoot, parentUuid) {
43
+ var _behaviorConfig$behav;
44
+ if (!behaviorConfig || !deviceModelRoot) return;
45
+ var anims = Array.isArray(behaviorConfig) ? behaviorConfig : (_behaviorConfig$behav = behaviorConfig.behaviors) !== null && _behaviorConfig$behav !== void 0 ? _behaviorConfig$behav : [];
46
+ if (!anims.length) return;
47
+ var key = this._key(parentUuid, attachmentId);
48
+ var entries = [];
49
+
50
+ // Capture the device root's world orientation once so each entry can
51
+ // convert the configured axis from device-local space to world space.
52
+ var deviceWorldQuat = new THREE.Quaternion();
53
+ deviceModelRoot.getWorldQuaternion(deviceWorldQuat);
54
+
55
+ // Compute the model's native max dimension so rotAxisOffset values (stored
56
+ // in dialog-viewer-world units, where the model is normalised to 1 unit)
57
+ // can be scaled to runtime-world units. viewerMaxDim = 1 / ns_viewer.
58
+ var _deviceBox = new THREE.Box3().setFromObject(deviceModelRoot);
59
+ var _deviceSize = new THREE.Vector3();
60
+ _deviceBox.getSize(_deviceSize);
61
+ var viewerMaxDim = Math.max(_deviceSize.x, _deviceSize.y, _deviceSize.z) || 1;
62
+ var _iterator = _createForOfIteratorHelper(anims),
63
+ _step;
64
+ try {
65
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
66
+ var anim = _step.value;
67
+ var mesh = this._resolveMesh(anim, deviceModelRoot);
68
+ if (!mesh) {
69
+ console.warn("[IoBehaviorManager] Could not find mesh for animation \"".concat(anim.name || anim.stateVariable, "\" (uuid: ").concat(anim.meshUuid, ", name: \"").concat(anim.meshName, "\")"));
70
+ continue;
71
+ }
72
+ var worldPos = new THREE.Vector3();
73
+ mesh.getWorldPosition(worldPos);
74
+ var worldQuat = new THREE.Quaternion();
75
+ mesh.getWorldQuaternion(worldQuat);
76
+ var box = new THREE.Box3().setFromObject(mesh);
77
+ var worldCenter = new THREE.Vector3();
78
+ if (!box.isEmpty()) box.getCenter(worldCenter);else worldCenter.copy(worldPos);
79
+ entries.push({
80
+ anim: anim,
81
+ mesh: mesh,
82
+ origPos: mesh.position.clone(),
83
+ origRot: mesh.rotation.clone(),
84
+ origWorldPos: worldPos,
85
+ origWorldQuat: worldQuat,
86
+ origWorldCenter: worldCenter,
87
+ deviceWorldQuat: deviceWorldQuat.clone(),
88
+ viewerMaxDim: viewerMaxDim
89
+ });
90
+ }
91
+ } catch (err) {
92
+ _iterator.e(err);
93
+ } finally {
94
+ _iterator.f();
95
+ }
96
+ if (entries.length) {
97
+ this._entries.set(key, entries);
98
+ console.log("[IoBehaviorManager] Loaded ".concat(entries.length, " animation(s) for attachment \"").concat(attachmentId, "\" (parent: ").concat(parentUuid, ") \u2014 stateVariables: ").concat(entries.map(function (e) {
99
+ return e.anim.stateVariable;
100
+ }).join(', ')));
101
+ } else {
102
+ console.warn("[IoBehaviorManager] No mesh entries resolved for attachment \"".concat(attachmentId, "\" \u2014 behaviorConfig had ").concat(anims.length, " entries but none matched a mesh"));
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Apply animations triggered by an IO device state change.
108
+ * Should be called in parallel with BehaviorManager.triggerState().
109
+ *
110
+ * @param {string} attachmentId - Raw attachment key (not scoped)
111
+ * @param {string} dataPointId - The data point / state variable id that changed
112
+ * @param {*} value - New state value
113
+ * @param {string} parentUuid - UUID of the host component
114
+ */
115
+ }, {
116
+ key: "triggerState",
117
+ value: function triggerState(attachmentId, dataPointId, value, parentUuid) {
118
+ var key = this._key(parentUuid, attachmentId);
119
+ var entries = this._entries.get(key);
120
+ if (!(entries !== null && entries !== void 0 && entries.length)) return;
121
+ var _iterator2 = _createForOfIteratorHelper(entries),
122
+ _step2;
123
+ try {
124
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
125
+ var entry = _step2.value;
126
+ if (entry.anim.stateVariable !== dataPointId) continue;
127
+ this._applyAnimation(entry, value);
128
+ }
129
+ } catch (err) {
130
+ _iterator2.e(err);
131
+ } finally {
132
+ _iterator2.f();
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Return tooltip-compatible data point definitions derived from the loaded
138
+ * animation entries for a given attachment. Used by componentTooltipManager
139
+ * to replace the static ioConfig.states[] snapshot with the richer animation
140
+ * state definitions created in the Animate window.
141
+ *
142
+ * One dp object is emitted per unique stateVariable; multiple mesh entries
143
+ * that share the same stateVariable are collapsed into one.
144
+ *
145
+ * Returned dp shape (matches what _buildDataPointRow / _buildInputControl expect):
146
+ * {
147
+ * id: string, // stateVariable name
148
+ * name: string, // human-readable label (anim.name or stateVariable)
149
+ * stateType: string, // normalised to 'binary' | 'enum' | 'number'
150
+ * stateConfig: Object, // { onLabel?, offLabel?, options?, min?, max?, unit? }
151
+ * defaultValue: any, // sensible default for the stateType
152
+ * direction: 'input' // all animation-driven states are interactive
153
+ * }
154
+ *
155
+ * @param {string} parentUuid
156
+ * @param {string} attachmentId
157
+ * @returns {Object[]} Array of dp objects, or empty array if none loaded.
158
+ */
159
+ }, {
160
+ key: "getAnimationDataPoints",
161
+ value: function getAnimationDataPoints(parentUuid, attachmentId) {
162
+ var _this2 = this;
163
+ var hitMesh = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null;
164
+ var key = this._key(parentUuid, attachmentId);
165
+ var entries = this._entries.get(key);
166
+ if (!(entries !== null && entries !== void 0 && entries.length)) return [];
167
+
168
+ // When a specific mesh was clicked, filter to only animations whose target
169
+ // mesh is the clicked mesh or an ancestor of it (e.g. the clicked primitive
170
+ // is inside a group that is the animation target).
171
+ var filtered = entries;
172
+ if (hitMesh) {
173
+ var matching = entries.filter(function (e) {
174
+ return _this2._isMeshOrDescendant(hitMesh, e.mesh);
175
+ });
176
+ if (matching.length > 0) filtered = matching;
177
+ }
178
+
179
+ // Collapse multiple mesh entries that share the same stateVariable
180
+ var seen = new Map(); // stateVariable → anim
181
+ var _iterator3 = _createForOfIteratorHelper(filtered),
182
+ _step3;
183
+ try {
184
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
185
+ var anim = _step3.value.anim;
186
+ if (!seen.has(anim.stateVariable)) {
187
+ seen.set(anim.stateVariable, anim);
188
+ }
189
+ }
190
+ } catch (err) {
191
+ _iterator3.e(err);
192
+ } finally {
193
+ _iterator3.f();
194
+ }
195
+ var dps = [];
196
+ var _iterator4 = _createForOfIteratorHelper(seen),
197
+ _step4;
198
+ try {
199
+ for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
200
+ var _step4$value = _slicedToArray(_step4.value, 2),
201
+ stateVar = _step4$value[0],
202
+ _anim = _step4$value[1];
203
+ // Normalise stateType from AnimateDevicesDialog variants
204
+ var stateType = void 0;
205
+ var raw = (_anim.stateType || '').toLowerCase();
206
+ if (raw === 'binary' || raw === 'boolean') {
207
+ stateType = 'binary';
208
+ } else if (raw === 'enum') {
209
+ stateType = 'enum';
210
+ } else {
211
+ // 'continuous', 'range', 'number', or anything else → numeric slider
212
+ stateType = 'number';
213
+ }
214
+
215
+ // Derive stateConfig from mappings
216
+ var stateConfig = {};
217
+ var mappingValues = (_anim.mappings || []).map(function (m) {
218
+ return m.stateValue;
219
+ });
220
+ if (stateType === 'enum') {
221
+ stateConfig.options = _toConsumableArray(new Set(mappingValues.map(String)));
222
+ } else if (stateType === 'number') {
223
+ var nums = mappingValues.map(Number).filter(function (n) {
224
+ return !isNaN(n);
225
+ });
226
+ if (nums.length) {
227
+ stateConfig.min = Math.min.apply(Math, _toConsumableArray(nums));
228
+ stateConfig.max = Math.max.apply(Math, _toConsumableArray(nums));
229
+ }
230
+ }
231
+
232
+ // Sensible default values
233
+ var defaultValue = void 0;
234
+ if (stateType === 'binary') {
235
+ defaultValue = 0;
236
+ } else if (stateType === 'enum') {
237
+ var _stateConfig$options$, _stateConfig$options;
238
+ defaultValue = (_stateConfig$options$ = (_stateConfig$options = stateConfig.options) === null || _stateConfig$options === void 0 ? void 0 : _stateConfig$options[0]) !== null && _stateConfig$options$ !== void 0 ? _stateConfig$options$ : '';
239
+ } else {
240
+ var _stateConfig$min;
241
+ defaultValue = (_stateConfig$min = stateConfig.min) !== null && _stateConfig$min !== void 0 ? _stateConfig$min : 0;
242
+ }
243
+ dps.push({
244
+ id: stateVar,
245
+ name: _anim.name || stateVar,
246
+ stateType: stateType,
247
+ stateConfig: stateConfig,
248
+ defaultValue: defaultValue,
249
+ direction: 'input'
250
+ });
251
+ }
252
+ } catch (err) {
253
+ _iterator4.e(err);
254
+ } finally {
255
+ _iterator4.f();
256
+ }
257
+ return dps;
258
+ }
259
+
260
+ /**
261
+ * Return the Three.js mesh objects that are animated for a given attachment.
262
+ * Used by IoOutlineManager to include animated meshes in the outline.
263
+ *
264
+ * @param {string} parentUuid
265
+ * @param {string} attachmentId
266
+ * @returns {THREE.Object3D[]}
267
+ */
268
+ }, {
269
+ key: "getAnimatedMeshes",
270
+ value: function getAnimatedMeshes(parentUuid, attachmentId) {
271
+ var key = this._key(parentUuid, attachmentId);
272
+ var entries = this._entries.get(key);
273
+ if (!(entries !== null && entries !== void 0 && entries.length)) return [];
274
+ // Deduplicate — multiple animations can target the same mesh
275
+ return _toConsumableArray(new Set(entries.map(function (e) {
276
+ return e.mesh;
277
+ })));
278
+ }
279
+
280
+ /**
281
+ * Remove all animation entries associated with a given host component.
282
+ * Call when a component is removed from the scene.
283
+ *
284
+ * @param {string} parentUuid
285
+ */
286
+ }, {
287
+ key: "unloadForComponent",
288
+ value: function unloadForComponent(parentUuid) {
289
+ var prefix = "".concat(parentUuid, "::");
290
+ var _iterator5 = _createForOfIteratorHelper(this._entries.keys()),
291
+ _step5;
292
+ try {
293
+ for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
294
+ var key = _step5.value;
295
+ if (key.startsWith(prefix)) {
296
+ this._entries.delete(key);
297
+ }
298
+ }
299
+ } catch (err) {
300
+ _iterator5.e(err);
301
+ } finally {
302
+ _iterator5.f();
303
+ }
304
+ }
305
+ }, {
306
+ key: "dispose",
307
+ value: function dispose() {
308
+ this._entries.clear();
309
+ _superPropGet(IoBehaviorManager, "dispose", this, 3)([]);
310
+ }
311
+
312
+ // ─────────────────────────────────────────────────────────────────────────
313
+ // PRIVATE HELPERS
314
+ // ─────────────────────────────────────────────────────────────────────────
315
+ }, {
316
+ key: "_key",
317
+ value: function _key(parentUuid, attachmentId) {
318
+ return "".concat(parentUuid, "::").concat(attachmentId);
319
+ }
320
+
321
+ /**
322
+ * Returns true if `candidate` is `ancestor` or any descendant of `ancestor`.
323
+ * @param {THREE.Object3D} candidate
324
+ * @param {THREE.Object3D} ancestor
325
+ */
326
+ }, {
327
+ key: "_isMeshOrDescendant",
328
+ value: function _isMeshOrDescendant(candidate, ancestor) {
329
+ var obj = candidate;
330
+ while (obj) {
331
+ if (obj === ancestor) return true;
332
+ obj = obj.parent;
333
+ }
334
+ return false;
335
+ }
336
+
337
+ /**
338
+ * Find the mesh inside `root` using UUID first, then name as fallback.
339
+ * GLTFLoader assigns fresh UUIDs on every load, so name is the reliable key.
340
+ * @param {Object} anim
341
+ * @param {THREE.Object3D} root
342
+ * @returns {THREE.Object3D|null}
343
+ */
344
+ }, {
345
+ key: "_resolveMesh",
346
+ value: function _resolveMesh(anim, root) {
347
+ var found = null;
348
+ root.traverse(function (obj) {
349
+ if (found) return;
350
+
351
+ // Prefer UUID match (works when the device was cloned from a cached instance
352
+ // whose UUIDs were preserved)
353
+ if (anim.meshUuid && obj.uuid === anim.meshUuid) {
354
+ found = obj;
355
+ return;
356
+ }
357
+
358
+ // Reliable fallback: mesh name
359
+ if (anim.meshName && obj.name === anim.meshName) {
360
+ found = obj;
361
+ }
362
+ });
363
+ return found;
364
+ }
365
+
366
+ /**
367
+ * Resolve which mapping row to use and apply all declared transform types.
368
+ * @param {{ anim, mesh, origPos, origRot }} entry
369
+ * @param {*} value - Current state value
370
+ */
371
+ }, {
372
+ key: "_applyAnimation",
373
+ value: function _applyAnimation(entry, value) {
374
+ var anim = entry.anim,
375
+ mesh = entry.mesh,
376
+ origPos = entry.origPos,
377
+ origRot = entry.origRot,
378
+ origWorldPos = entry.origWorldPos,
379
+ origWorldQuat = entry.origWorldQuat,
380
+ origWorldCenter = entry.origWorldCenter,
381
+ deviceWorldQuat = entry.deviceWorldQuat,
382
+ viewerMaxDim = entry.viewerMaxDim;
383
+ var mapping = this._resolveMapping(anim, value);
384
+ if (!mapping) return;
385
+ var types = anim.transformTypes || [];
386
+ var _iterator6 = _createForOfIteratorHelper(types),
387
+ _step6;
388
+ try {
389
+ for (_iterator6.s(); !(_step6 = _iterator6.n()).done;) {
390
+ var type = _step6.value;
391
+ if (type === 'translation') {
392
+ this._applyTranslation(mesh, origPos, mapping.transform);
393
+ } else if (type === 'rotation') {
394
+ this._applyRotation(mesh, origPos, origRot, anim, mapping.rotationTransform, origWorldPos, origWorldQuat, origWorldCenter, deviceWorldQuat, viewerMaxDim);
395
+ } else if (type === 'color') {
396
+ this._applyColor(mesh, mapping.colorTransform);
397
+ }
398
+ }
399
+ } catch (err) {
400
+ _iterator6.e(err);
401
+ } finally {
402
+ _iterator6.f();
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Find the mapping row that matches `value` for the given animation entry.
408
+ *
409
+ * - binary / enum: find where mapping.stateValue == value (loose equality so
410
+ * JSON-deserialised "true" matches boolean true)
411
+ * - continuous: linear interpolation between ordered anchor points
412
+ *
413
+ * @returns {Object|null} The matched/interpolated mapping row, or null.
414
+ */
415
+ }, {
416
+ key: "_resolveMapping",
417
+ value: function _resolveMapping(anim, value) {
418
+ var mappings = anim.mappings;
419
+ if (!(mappings !== null && mappings !== void 0 && mappings.length)) return null;
420
+
421
+ // Normalise stateType variants saved by AnimateDevicesDialog
422
+ // ('boolean' → 'binary', 'range' → 'continuous')
423
+ var raw = (anim.stateType || '').toLowerCase();
424
+ var stateType;
425
+ if (raw === 'binary' || raw === 'boolean') {
426
+ stateType = 'binary';
427
+ } else if (raw === 'continuous' || raw === 'range') {
428
+ stateType = 'continuous';
429
+ } else {
430
+ stateType = raw;
431
+ }
432
+ if (stateType === 'binary' || stateType === 'enum') {
433
+ var _mappings$find;
434
+ // eslint-disable-next-line eqeqeq
435
+ return (_mappings$find = mappings.find(function (m) {
436
+ return m.stateValue == value;
437
+ })) !== null && _mappings$find !== void 0 ? _mappings$find : null;
438
+ }
439
+ if (stateType === 'continuous') {
440
+ var num = Number(value);
441
+ if (isNaN(num)) return null;
442
+
443
+ // Sort anchors by numeric stateValue
444
+ var sorted = _toConsumableArray(mappings).filter(function (m) {
445
+ return m.stateValue != null && !isNaN(Number(m.stateValue));
446
+ }).sort(function (a, b) {
447
+ return Number(a.stateValue) - Number(b.stateValue);
448
+ });
449
+ if (!sorted.length) return null;
450
+ if (sorted.length === 1) return sorted[0];
451
+
452
+ // Clamp to range
453
+ if (num <= Number(sorted[0].stateValue)) return sorted[0];
454
+ if (num >= Number(sorted[sorted.length - 1].stateValue)) return sorted[sorted.length - 1];
455
+
456
+ // Find surrounding anchors and interpolate
457
+ for (var i = 0; i < sorted.length - 1; i++) {
458
+ var lo = sorted[i];
459
+ var hi = sorted[i + 1];
460
+ var loVal = Number(lo.stateValue);
461
+ var hiVal = Number(hi.stateValue);
462
+ if (num >= loVal && num <= hiVal) {
463
+ var t = (num - loVal) / (hiVal - loVal);
464
+ return this._lerpMapping(lo, hi, t);
465
+ }
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+
471
+ /**
472
+ * Linear-interpolate between two mapping rows.
473
+ * @param {Object} lo - lower anchor mapping
474
+ * @param {Object} hi - upper anchor mapping
475
+ * @param {number} t - 0..1
476
+ */
477
+ }, {
478
+ key: "_lerpMapping",
479
+ value: function _lerpMapping(lo, hi, t) {
480
+ var _lo$transform$x, _lo$transform, _hi$transform$x, _hi$transform, _lo$transform$y, _lo$transform2, _hi$transform$y, _hi$transform2, _lo$transform$z, _lo$transform3, _hi$transform$z, _hi$transform3;
481
+ var lerp = function lerp(a, b) {
482
+ return a + (b - a) * t;
483
+ };
484
+ var transform = {
485
+ x: lerp((_lo$transform$x = (_lo$transform = lo.transform) === null || _lo$transform === void 0 ? void 0 : _lo$transform.x) !== null && _lo$transform$x !== void 0 ? _lo$transform$x : 0, (_hi$transform$x = (_hi$transform = hi.transform) === null || _hi$transform === void 0 ? void 0 : _hi$transform.x) !== null && _hi$transform$x !== void 0 ? _hi$transform$x : 0),
486
+ y: lerp((_lo$transform$y = (_lo$transform2 = lo.transform) === null || _lo$transform2 === void 0 ? void 0 : _lo$transform2.y) !== null && _lo$transform$y !== void 0 ? _lo$transform$y : 0, (_hi$transform$y = (_hi$transform2 = hi.transform) === null || _hi$transform2 === void 0 ? void 0 : _hi$transform2.y) !== null && _hi$transform$y !== void 0 ? _hi$transform$y : 0),
487
+ z: lerp((_lo$transform$z = (_lo$transform3 = lo.transform) === null || _lo$transform3 === void 0 ? void 0 : _lo$transform3.z) !== null && _lo$transform$z !== void 0 ? _lo$transform$z : 0, (_hi$transform$z = (_hi$transform3 = hi.transform) === null || _hi$transform3 === void 0 ? void 0 : _hi$transform3.z) !== null && _hi$transform$z !== void 0 ? _hi$transform$z : 0)
488
+ };
489
+ var rotationTransform = lerp(typeof lo.rotationTransform === 'number' ? lo.rotationTransform : 0, typeof hi.rotationTransform === 'number' ? hi.rotationTransform : 0);
490
+
491
+ // Color interpolation: convert hex → RGB components → lerp → back to hex
492
+ var colorTransform = this._lerpHex(lo.colorTransform, hi.colorTransform, t);
493
+ return {
494
+ stateValue: null,
495
+ transform: transform,
496
+ rotationTransform: rotationTransform,
497
+ colorTransform: colorTransform
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Interpolate between two hex colour strings.
503
+ */
504
+ }, {
505
+ key: "_lerpHex",
506
+ value: function _lerpHex(hexA, hexB, t) {
507
+ try {
508
+ var ca = new THREE.Color(hexA);
509
+ var cb = new THREE.Color(hexB);
510
+ ca.lerp(cb, t);
511
+ return "#".concat(ca.getHexString());
512
+ } catch (_unused) {
513
+ return hexA !== null && hexA !== void 0 ? hexA : '#ffffff';
514
+ }
515
+ }
516
+
517
+ // ─────────────────────────────────────────────────────────────────────────
518
+ // TRANSFORM APPLIERS
519
+ // ─────────────────────────────────────────────────────────────────────────
520
+
521
+ /**
522
+ * Apply a position delta relative to the mesh's original position.
523
+ * @param {THREE.Object3D} mesh
524
+ * @param {THREE.Vector3} origPos
525
+ * @param {{ x, y, z }} transform - Deltas
526
+ */
527
+ }, {
528
+ key: "_applyTranslation",
529
+ value: function _applyTranslation(mesh, origPos, transform) {
530
+ var _transform$x, _transform$y, _transform$z;
531
+ if (!transform) return;
532
+ // X and Y are negated to match the sign convention used in the AnimateDevicesDialog
533
+ // preview (_syncViewerTransform negates x and y before calling setMeshPreviewOffset).
534
+ // Z is added directly (no negation) — matching the dialog's z handling.
535
+ mesh.position.set(origPos.x - ((_transform$x = transform.x) !== null && _transform$x !== void 0 ? _transform$x : 0), origPos.y - ((_transform$y = transform.y) !== null && _transform$y !== void 0 ? _transform$y : 0), origPos.z + ((_transform$z = transform.z) !== null && _transform$z !== void 0 ? _transform$z : 0));
536
+ }
537
+
538
+ /**
539
+ * Apply a rotation around an arbitrary pivot point using world-space quaternion
540
+ * math matching the ReconstructionViewer's setMeshPreviewRotationAxis approach.
541
+ * This ensures the runtime axis/pivot matches what the user configured in the
542
+ * animation dialog, regardless of the device's parent transform in the scene.
543
+ *
544
+ * @param {THREE.Object3D} mesh
545
+ * @param {THREE.Vector3} origPos - local position at load time (unused, kept for signature compat)
546
+ * @param {THREE.Euler} origRot - local rotation at load time (unused)
547
+ * @param {Object} anim
548
+ * @param {number} angleDeg - Degrees
549
+ * @param {THREE.Vector3} origWorldPos - world position at load time
550
+ * @param {THREE.Quaternion} origWorldQuat - world quaternion at load time
551
+ * @param {THREE.Vector3} origWorldCenter - world bounding-box center at load time
552
+ */
553
+ }, {
554
+ key: "_applyRotation",
555
+ value: function _applyRotation(mesh, origPos, origRot, anim, angleDeg, origWorldPos, origWorldQuat, origWorldCenter, deviceWorldQuat, viewerMaxDim) {
556
+ var _anim$rotAxis, _anim$rotAxisOffset;
557
+ var angle = THREE.MathUtils.degToRad(typeof angleDeg === 'number' ? angleDeg : 0);
558
+ var axis = ((_anim$rotAxis = anim.rotAxis) !== null && _anim$rotAxis !== void 0 ? _anim$rotAxis : 'x').toLowerCase();
559
+ var off = (_anim$rotAxisOffset = anim.rotAxisOffset) !== null && _anim$rotAxisOffset !== void 0 ? _anim$rotAxisOffset : {
560
+ x: 0,
561
+ y: 0,
562
+ z: 0
563
+ };
564
+
565
+ // Local axis in the device's coordinate system
566
+ var localAxisVec = new THREE.Vector3(axis === 'x' ? 1 : 0, axis === 'y' ? 1 : 0, axis === 'z' ? 1 : 0);
567
+ if (origWorldPos && origWorldQuat && origWorldCenter) {
568
+ // Transform the configured axis from device-local space into world space
569
+ // so that 'X' means the device's local X, not the world X.
570
+ var worldAxisVec = localAxisVec.clone().applyQuaternion(deviceWorldQuat || new THREE.Quaternion());
571
+
572
+ // rotAxisOffset is stored in dialog-viewer-world space where the model root
573
+ // has been rotated Rot_Z(π) by default (ReconstructionViewer's else branch).
574
+ // Translation uses the same convention and negates X/Y to compensate.
575
+ // For rotation offset we apply the same compensation mathematically:
576
+ // offset_runtime = deviceWorldQuat × Rot_Z(π) × offset_dialog × scale
577
+ // This maps from dialog world space (post Z+180° flip) → model local → world.
578
+ var scale = viewerMaxDim || 1;
579
+ var rotZ180 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI);
580
+ var qCombined = (deviceWorldQuat || new THREE.Quaternion()).clone().multiply(rotZ180);
581
+ var offWorld = new THREE.Vector3((off.x || 0) * scale, (off.y || 0) * scale, (off.z || 0) * scale).applyQuaternion(qCombined);
582
+ var pivot = origWorldCenter.clone().add(offWorld);
583
+ var deltaQuat = new THREE.Quaternion().setFromAxisAngle(worldAxisVec, angle);
584
+
585
+ // Rotate world position around pivot
586
+ var offsetVec = origWorldPos.clone().sub(pivot);
587
+ offsetVec.applyQuaternion(deltaQuat);
588
+ var newWorldPos = pivot.clone().add(offsetVec);
589
+
590
+ // Compose world quaternion
591
+ var newWorldQuat = deltaQuat.clone().multiply(origWorldQuat);
592
+
593
+ // Convert world position → parent local space
594
+ if (mesh.parent) {
595
+ mesh.parent.worldToLocal(newWorldPos);
596
+ }
597
+ mesh.position.copy(newWorldPos);
598
+
599
+ // Convert quaternion → parent local space
600
+ if (mesh.parent) {
601
+ var parentWorldQuat = new THREE.Quaternion();
602
+ mesh.parent.getWorldQuaternion(parentWorldQuat);
603
+ parentWorldQuat.invert();
604
+ newWorldQuat.premultiply(parentWorldQuat);
605
+ }
606
+ mesh.quaternion.copy(newWorldQuat);
607
+ } else {
608
+ var _off$x, _off$y, _off$z;
609
+ // Fallback for entries loaded without world data
610
+ var _pivot = new THREE.Vector3((_off$x = off.x) !== null && _off$x !== void 0 ? _off$x : 0, (_off$y = off.y) !== null && _off$y !== void 0 ? _off$y : 0, (_off$z = off.z) !== null && _off$z !== void 0 ? _off$z : 0);
611
+ var delta = origPos.clone().sub(_pivot);
612
+ delta.applyAxisAngle(localAxisVec, angle);
613
+ mesh.position.copy(_pivot).add(delta);
614
+ mesh.rotation[axis] = origRot[axis] + angle;
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Apply a colour to all Mesh descendants of `mesh`.
620
+ * Creates a cloned material per mesh on first call to avoid shared-material
621
+ * cross-contamination between different device instances.
622
+ *
623
+ * @param {THREE.Object3D} mesh
624
+ * @param {string} colorHex - e.g. '#ff0000'
625
+ */
626
+ }, {
627
+ key: "_applyColor",
628
+ value: function _applyColor(mesh, colorHex) {
629
+ if (!colorHex) return;
630
+ mesh.traverse(function (obj) {
631
+ if (!obj.isMesh || !obj.material) return;
632
+
633
+ // Ensure this mesh has its own material instance
634
+ if (!obj.userData._materialCloned) {
635
+ obj.material = obj.material.clone();
636
+ obj.userData._materialCloned = true;
637
+ }
638
+ try {
639
+ obj.material.color.set(colorHex);
640
+ } catch (e) {
641
+ // Material may not have a color property (e.g. MeshDepthMaterial)
642
+ }
643
+ });
644
+ }
645
+ }]);
646
+ }(BaseDisposable);
647
+
648
+ export { IoBehaviorManager };
@@ -445,6 +445,7 @@ var TransformControlsManager = /*#__PURE__*/function () {
445
445
  raycaster.setFromCamera(mouse, _this4.camera);
446
446
  var allIntersects = raycaster.intersectObjects(_this4.scene.children, true);
447
447
  var ioDeviceObject = null;
448
+ var hitMesh = null;
448
449
  var _iterator = _createForOfIteratorHelper(allIntersects),
449
450
  _step;
450
451
  try {
@@ -459,7 +460,10 @@ var TransformControlsManager = /*#__PURE__*/function () {
459
460
  }
460
461
  obj = obj.parent;
461
462
  }
462
- if (ioDeviceObject) break;
463
+ if (ioDeviceObject) {
464
+ hitMesh = hit.object;
465
+ break;
466
+ }
463
467
  }
464
468
  } catch (err) {
465
469
  _iterator.e(err);
@@ -474,7 +478,7 @@ var TransformControlsManager = /*#__PURE__*/function () {
474
478
  _this4._ioDragStartY = event.clientY;
475
479
  _this4._ioDragMoved = false;
476
480
  if (_this4.orbitControls) _this4.orbitControls.enabled = false;
477
- _this4.callbacks.onIODeviceDrag(ioDeviceObject, 0, true);
481
+ _this4.callbacks.onIODeviceDrag(ioDeviceObject, 0, true, hitMesh);
478
482
  var onMove = function onMove(e) {
479
483
  var dx = e.clientX - _this4._ioDragStartX;
480
484
  var dy = e.clientY - _this4._ioDragStartY;