@2112-lab/central-plant 0.3.26 → 0.3.27

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.
@@ -3611,6 +3611,34 @@ function computeIODeviceBoundingBoxes(componentObject) {
3611
3611
  * const helpers = createSelectionBoxHelpers(pumpModel, 0x00ff00)
3612
3612
  * helpers.forEach(h => scene.add(h))
3613
3613
  */
3614
+ /**
3615
+ * Returns a filtered bounding box for `object`, using a cache stored on
3616
+ * `object.userData._filteredBBoxCache`. The cache key is the serialised
3617
+ * world-matrix elements string; if the object has moved the cache is
3618
+ * automatically invalidated and recomputed.
3619
+ *
3620
+ * This avoids the expensive full-traverse on every selection event for
3621
+ * large smart components with many child meshes.
3622
+ *
3623
+ * @param {THREE.Object3D} object
3624
+ * @param {string[]} excludeTypes
3625
+ * @returns {THREE.Box3}
3626
+ */
3627
+ function computeFilteredBoundingBoxCached(object, excludeTypes) {
3628
+ object.updateMatrixWorld(true);
3629
+ var matrixKey = object.matrixWorld.elements.join(',');
3630
+ var cache = object.userData._filteredBBoxCache;
3631
+ if (cache && cache.matrixKey === matrixKey) {
3632
+ return new THREE__namespace.Box3(_construct(THREE__namespace.Vector3, _toConsumableArray(cache.min)), _construct(THREE__namespace.Vector3, _toConsumableArray(cache.max)));
3633
+ }
3634
+ var box = computeFilteredBoundingBox(object, excludeTypes);
3635
+ object.userData._filteredBBoxCache = {
3636
+ matrixKey: matrixKey,
3637
+ min: box.min.toArray(),
3638
+ max: box.max.toArray()
3639
+ };
3640
+ return box;
3641
+ }
3614
3642
  function createSelectionBoxHelpers(object) {
3615
3643
  var _object$children;
3616
3644
  var color = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0x00ff00;
@@ -3624,7 +3652,7 @@ function createSelectionBoxHelpers(object) {
3624
3652
  });
3625
3653
  if (hasIODevices) {
3626
3654
  // 1. Create filtered helper for the component body
3627
- var filteredBox = computeFilteredBoundingBox(object, excludeTypes);
3655
+ var filteredBox = computeFilteredBoundingBoxCached(object, excludeTypes);
3628
3656
  var componentHelper = _createBoxHelperFromBox3(filteredBox, color);
3629
3657
  componentHelper.isHelper = true;
3630
3658
  componentHelper.userData = {
@@ -3634,33 +3662,6 @@ function createSelectionBoxHelpers(object) {
3634
3662
  excludeTypes: excludeTypes
3635
3663
  };
3636
3664
  helpers.push(componentHelper);
3637
-
3638
- // 2. Create individual helpers for each io-device
3639
- var _iterator2 = _createForOfIteratorHelper(object.children),
3640
- _step2;
3641
- try {
3642
- for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
3643
- var _child$userData3;
3644
- var child = _step2.value;
3645
- if (((_child$userData3 = child.userData) === null || _child$userData3 === void 0 ? void 0 : _child$userData3.objectType) !== 'io-device') continue;
3646
- var deviceBox = new THREE__namespace.Box3().setFromObject(child);
3647
- if (deviceBox.isEmpty()) continue;
3648
- var deviceHelper = _createBoxHelperFromBox3(deviceBox, color);
3649
- deviceHelper.isHelper = true;
3650
- deviceHelper.userData = {
3651
- isBoundingBox: true,
3652
- sourceObjectUuid: child.uuid,
3653
- isFiltered: false,
3654
- isIODevice: true,
3655
- parentComponentUuid: object.uuid
3656
- };
3657
- helpers.push(deviceHelper);
3658
- }
3659
- } catch (err) {
3660
- _iterator2.e(err);
3661
- } finally {
3662
- _iterator2.f();
3663
- }
3664
3665
  } else {
3665
3666
  // Standard BoxHelper for non-smart objects
3666
3667
  var boxHelper = new THREE__namespace.BoxHelper(object, color);
@@ -3684,11 +3685,11 @@ function createSelectionBoxHelpers(object) {
3684
3685
  * @param {THREE.Scene} scene - The scene (for finding objects by uuid)
3685
3686
  */
3686
3687
  function updateSelectionBoxHelpers(helpers, selectedObjects, scene) {
3687
- var _iterator3 = _createForOfIteratorHelper(helpers),
3688
- _step3;
3688
+ var _iterator2 = _createForOfIteratorHelper(helpers),
3689
+ _step2;
3689
3690
  try {
3690
3691
  var _loop = function _loop() {
3691
- var helper = _step3.value;
3692
+ var helper = _step2.value;
3692
3693
  var _helper$userData = helper.userData,
3693
3694
  sourceObjectUuid = _helper$userData.sourceObjectUuid,
3694
3695
  isFiltered = _helper$userData.isFiltered,
@@ -3711,25 +3712,21 @@ function updateSelectionBoxHelpers(helpers, selectedObjects, scene) {
3711
3712
  if (!sourceObject) return 1; // continue
3712
3713
  sourceObject.updateMatrixWorld(true);
3713
3714
  if (isFiltered && excludeTypes) {
3714
- // Recompute filtered bbox
3715
- var box = computeFilteredBoundingBox(sourceObject, excludeTypes);
3715
+ // Recompute filtered bbox (uses cache when the object hasn't moved)
3716
+ var box = computeFilteredBoundingBoxCached(sourceObject, excludeTypes);
3716
3717
  _updateBoxHelperPositions(helper, box);
3717
- } else if (isIODevice) {
3718
- // Recompute io-device bbox
3719
- var _box = new THREE__namespace.Box3().setFromObject(sourceObject);
3720
- _updateBoxHelperPositions(helper, _box);
3721
3718
  } else if (helper.update) {
3722
3719
  // Standard BoxHelper — use built-in update
3723
3720
  helper.update();
3724
3721
  }
3725
3722
  };
3726
- for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
3723
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
3727
3724
  if (_loop()) continue;
3728
3725
  }
3729
3726
  } catch (err) {
3730
- _iterator3.e(err);
3727
+ _iterator2.e(err);
3731
3728
  } finally {
3732
- _iterator3.f();
3729
+ _iterator2.f();
3733
3730
  }
3734
3731
  }
3735
3732
 
@@ -3823,8 +3820,17 @@ var TransformControlsManager = /*#__PURE__*/function () {
3823
3820
  onModeChange: null,
3824
3821
  onObjectRemoved: null,
3825
3822
  onSelectionChanged: null,
3826
- onIODeviceClick: null
3823
+ onIODeviceClick: null,
3824
+ onIODeviceDrag: null,
3825
+ onIODeviceDragEnd: null
3827
3826
  };
3827
+
3828
+ // IO device drag tracking state
3829
+ this._ioDragMesh = null;
3830
+ this._ioDragStartX = 0;
3831
+ this._ioDragStartY = 0;
3832
+ this._ioDragMoved = false;
3833
+ this._suppressNextClick = false;
3828
3834
  this.init();
3829
3835
  }
3830
3836
 
@@ -3904,6 +3910,12 @@ var TransformControlsManager = /*#__PURE__*/function () {
3904
3910
  if (callbacks.onIODeviceClick) {
3905
3911
  this.callbacks.onIODeviceClick = callbacks.onIODeviceClick;
3906
3912
  }
3913
+ if (callbacks.onIODeviceDrag) {
3914
+ this.callbacks.onIODeviceDrag = callbacks.onIODeviceDrag;
3915
+ }
3916
+ if (callbacks.onIODeviceDragEnd) {
3917
+ this.callbacks.onIODeviceDragEnd = callbacks.onIODeviceDragEnd;
3918
+ }
3907
3919
  console.log('🔗 Transform controls callbacks registered');
3908
3920
  }
3909
3921
  /**
@@ -4110,6 +4122,11 @@ var TransformControlsManager = /*#__PURE__*/function () {
4110
4122
  value: function setupKeyboardControls() {
4111
4123
  var _this3 = this;
4112
4124
  this.eventHandlers.keydown = function (event) {
4125
+ // Restore perspective camera when pressing Escape from ortho face view
4126
+ if (event.code === 'Escape') {
4127
+ _this3._restorePerspectiveCamera();
4128
+ }
4129
+
4113
4130
  // Only handle keys when transform controls are active
4114
4131
  if (!_this3.transformControls.enabled || _this3.transformState.isTransforming) {
4115
4132
  return;
@@ -4144,9 +4161,82 @@ var TransformControlsManager = /*#__PURE__*/function () {
4144
4161
  var raycaster = new THREE__namespace.Raycaster();
4145
4162
  var mouse = new THREE__namespace.Vector2();
4146
4163
 
4147
- // Click handler: first click selects (bounding box), second click on same object shows tooltip
4164
+ // ── IO device drag ────────────────────────────────────────────────────
4165
+ // Detect pointerdown on an IO device mesh and convert a drag gesture into
4166
+ // state changes. Up/right = positive direction, down/left = negative.
4167
+ this.eventHandlers.pointerdown = function (event) {
4168
+ if (_this4.transformState.isTransforming) return;
4169
+ if (!_this4.callbacks.onIODeviceDrag) return;
4170
+ _this4._calculateMousePosition(event, mouse);
4171
+ raycaster.setFromCamera(mouse, _this4.camera);
4172
+ var allIntersects = raycaster.intersectObjects(_this4.scene.children, true);
4173
+ var ioDeviceObject = null;
4174
+ var _iterator = _createForOfIteratorHelper(allIntersects),
4175
+ _step;
4176
+ try {
4177
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
4178
+ var hit = _step.value;
4179
+ var obj = hit.object;
4180
+ while (obj) {
4181
+ var _obj$userData;
4182
+ if (((_obj$userData = obj.userData) === null || _obj$userData === void 0 ? void 0 : _obj$userData.objectType) === 'io-device') {
4183
+ ioDeviceObject = obj;
4184
+ break;
4185
+ }
4186
+ obj = obj.parent;
4187
+ }
4188
+ if (ioDeviceObject) break;
4189
+ }
4190
+ } catch (err) {
4191
+ _iterator.e(err);
4192
+ } finally {
4193
+ _iterator.f();
4194
+ }
4195
+ if (!ioDeviceObject) return;
4196
+
4197
+ // Begin session
4198
+ _this4._ioDragMesh = ioDeviceObject;
4199
+ _this4._ioDragStartX = event.clientX;
4200
+ _this4._ioDragStartY = event.clientY;
4201
+ _this4._ioDragMoved = false;
4202
+ if (_this4.orbitControls) _this4.orbitControls.enabled = false;
4203
+ _this4.callbacks.onIODeviceDrag(ioDeviceObject, 0, true);
4204
+ var onMove = function onMove(e) {
4205
+ var dx = e.clientX - _this4._ioDragStartX;
4206
+ var dy = e.clientY - _this4._ioDragStartY;
4207
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) _this4._ioDragMoved = true;
4208
+ // Up (−screenY) and right (+screenX) are positive
4209
+ var signedDelta = _this4._ioDragStartY - e.clientY + (e.clientX - _this4._ioDragStartX);
4210
+ _this4.callbacks.onIODeviceDrag(_this4._ioDragMesh, signedDelta, false);
4211
+ };
4212
+ var _onUp = function onUp() {
4213
+ window.removeEventListener('pointermove', onMove);
4214
+ window.removeEventListener('pointerup', _onUp);
4215
+ if (_this4.orbitControls) _this4.orbitControls.enabled = true;
4216
+ if (_this4._ioDragMoved) {
4217
+ // Suppress the click event that will fire after this pointerup
4218
+ _this4._suppressNextClick = true;
4219
+ }
4220
+ if (_this4.callbacks.onIODeviceDragEnd) {
4221
+ _this4.callbacks.onIODeviceDragEnd(_this4._ioDragMesh);
4222
+ }
4223
+ _this4._ioDragMesh = null;
4224
+ };
4225
+ window.addEventListener('pointermove', onMove);
4226
+ window.addEventListener('pointerup', _onUp);
4227
+ };
4228
+ this.renderer.domElement.addEventListener('pointerdown', this.eventHandlers.pointerdown);
4229
+
4230
+ // Click handler: left-click selects the object (bounding box + transform controls).
4231
+ // Right-click on a component shows the tooltip.
4148
4232
  this.eventHandlers.click = function (event) {
4149
4233
  var _targetObject$userDat;
4234
+ // Suppress click that follows an IO device drag
4235
+ if (_this4._suppressNextClick) {
4236
+ _this4._suppressNextClick = false;
4237
+ return;
4238
+ }
4239
+
4150
4240
  // Skip if currently transforming
4151
4241
  if (_this4.transformState.isTransforming) {
4152
4242
  return;
@@ -4159,15 +4249,15 @@ var TransformControlsManager = /*#__PURE__*/function () {
4159
4249
  // Check for direct io-device mesh click (before bounding box selection)
4160
4250
  if (_this4.callbacks.onIODeviceClick) {
4161
4251
  var allIntersects = raycaster.intersectObjects(_this4.scene.children, true);
4162
- var _iterator = _createForOfIteratorHelper(allIntersects),
4163
- _step;
4252
+ var _iterator2 = _createForOfIteratorHelper(allIntersects),
4253
+ _step2;
4164
4254
  try {
4165
- for (_iterator.s(); !(_step = _iterator.n()).done;) {
4166
- var hit = _step.value;
4255
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
4256
+ var hit = _step2.value;
4167
4257
  var obj = hit.object;
4168
4258
  while (obj) {
4169
- var _obj$userData;
4170
- if (((_obj$userData = obj.userData) === null || _obj$userData === void 0 ? void 0 : _obj$userData.objectType) === 'io-device') {
4259
+ var _obj$userData2;
4260
+ if (((_obj$userData2 = obj.userData) === null || _obj$userData2 === void 0 ? void 0 : _obj$userData2.objectType) === 'io-device') {
4171
4261
  _this4.callbacks.onIODeviceClick(obj);
4172
4262
  return;
4173
4263
  }
@@ -4175,9 +4265,9 @@ var TransformControlsManager = /*#__PURE__*/function () {
4175
4265
  }
4176
4266
  }
4177
4267
  } catch (err) {
4178
- _iterator.e(err);
4268
+ _iterator2.e(err);
4179
4269
  } finally {
4180
- _iterator.f();
4270
+ _iterator2.f();
4181
4271
  }
4182
4272
  }
4183
4273
 
@@ -4212,23 +4302,194 @@ var TransformControlsManager = /*#__PURE__*/function () {
4212
4302
  return;
4213
4303
  }
4214
4304
 
4215
- // Check if the clicked object is already selected
4216
- var isAlreadySelected = _this4.selectedObjects.length === 1 && _this4.selectedObjects[0] === targetObject;
4217
- if (isAlreadySelected) {
4218
- // Second click on already-selected object: Full selection including tooltips
4219
- _this4.selectObject(targetObject);
4220
- } else {
4221
- // First click on a new object: Transform controls and bounding box only
4222
- // Always deselect first (this clears tooltips via the callback)
4305
+ // Left-click: select for transform only (bounding box, no tooltip)
4306
+ if (!_this4.selectedObjects.includes(targetObject)) {
4223
4307
  _this4.deselectObject();
4224
-
4225
- // Then select the new object if there is one (this shows transform controls and bounding box)
4226
- if (targetObject) {
4227
- _this4.selectObjectForTransformOnly(targetObject);
4228
- }
4229
4308
  }
4309
+ _this4.selectObjectForTransformOnly(targetObject);
4230
4310
  };
4231
4311
  this.renderer.domElement.addEventListener('click', this.eventHandlers.click);
4312
+
4313
+ // Right-click handler: show tooltip for the clicked component.
4314
+ this.eventHandlers.contextmenu = function (event) {
4315
+ var _targetObject$userDat2;
4316
+ event.preventDefault();
4317
+ if (_this4.transformState.isTransforming) return;
4318
+ _this4._calculateMousePosition(event, mouse);
4319
+ raycaster.setFromCamera(mouse, _this4.camera);
4320
+ var targetObject = _this4._findTargetObject(raycaster, objectFilter);
4321
+ if (!targetObject) return;
4322
+ var objectType = (_targetObject$userDat2 = targetObject.userData) === null || _targetObject$userDat2 === void 0 ? void 0 : _targetObject$userDat2.objectType;
4323
+ if (objectType !== 'component' && objectType !== 'gateway' && objectType !== 'segment') return;
4324
+
4325
+ // Ensure the object is selected, then show the full tooltip.
4326
+ if (!_this4.selectedObjects.includes(targetObject)) {
4327
+ _this4.deselectObject();
4328
+ _this4.selectObjectForTransformOnly(targetObject);
4329
+ }
4330
+ _this4.selectObject(targetObject);
4331
+ };
4332
+ this.renderer.domElement.addEventListener('contextmenu', this.eventHandlers.contextmenu);
4333
+
4334
+ // Double-click handler: switch to orthographic camera fixed to the clicked face.
4335
+ // Press Escape to restore the perspective camera.
4336
+ this.eventHandlers.dblclick = function (event) {
4337
+ if (_this4.transformState.isTransforming) return;
4338
+ var sv = _this4.sceneViewer;
4339
+ if (!sv) return;
4340
+ _this4._calculateMousePosition(event, mouse);
4341
+ raycaster.setFromCamera(_this4.camera, _this4.camera); // ensure raycaster uses current camera
4342
+ raycaster.setFromCamera(mouse, _this4.camera);
4343
+
4344
+ // Raycast directly against scene meshes to obtain the face normal
4345
+ var allHits = raycaster.intersectObjects(_this4.scene.children, true).filter(function (h) {
4346
+ var _h$object$userData, _h$object$userData2;
4347
+ return !((_h$object$userData = h.object.userData) !== null && _h$object$userData !== void 0 && _h$object$userData.isTransformControls) && !((_h$object$userData2 = h.object.userData) !== null && _h$object$userData2 !== void 0 && _h$object$userData2.isBoundingBox);
4348
+ });
4349
+ if (!allHits.length || !allHits[0].face) return;
4350
+ var hit = allHits[0];
4351
+
4352
+ // Walk up to find the component root
4353
+ var componentObj = hit.object;
4354
+ while (componentObj) {
4355
+ var _componentObj$userDat;
4356
+ if (((_componentObj$userDat = componentObj.userData) === null || _componentObj$userDat === void 0 ? void 0 : _componentObj$userDat.objectType) === 'component') break;
4357
+ componentObj = componentObj.parent;
4358
+ }
4359
+ if (!componentObj) return;
4360
+
4361
+ // Transform face normal from local object space to world space
4362
+ var normalMatrix = new THREE__namespace.Matrix3().getNormalMatrix(hit.object.matrixWorld);
4363
+ var worldNormal = hit.face.normal.clone().applyMatrix3(normalMatrix).normalize();
4364
+
4365
+ // Snap to the closest cardinal axis (Z-up: ±X, ±Y, ±Z)
4366
+ var cardinals = [new THREE__namespace.Vector3(1, 0, 0), new THREE__namespace.Vector3(-1, 0, 0), new THREE__namespace.Vector3(0, 1, 0), new THREE__namespace.Vector3(0, -1, 0), new THREE__namespace.Vector3(0, 0, 1), new THREE__namespace.Vector3(0, 0, -1)];
4367
+ var faceDir = cardinals.reduce(function (best, c) {
4368
+ return c.dot(worldNormal) > best.dot(worldNormal) ? c : best;
4369
+ });
4370
+
4371
+ // Bounding box of the component
4372
+ var bbox = new THREE__namespace.Box3().setFromObject(componentObj);
4373
+ var center = bbox.getCenter(new THREE__namespace.Vector3());
4374
+ var size = bbox.getSize(new THREE__namespace.Vector3());
4375
+
4376
+ // Ortho half-extents: axes perpendicular to the face direction
4377
+ var isXFace = Math.abs(faceDir.x) > 0.5;
4378
+ var isYFace = Math.abs(faceDir.y) > 0.5;
4379
+ var halfW, halfH;
4380
+ if (isXFace) {
4381
+ halfW = size.y / 2;
4382
+ halfH = size.z / 2;
4383
+ } else if (isYFace) {
4384
+ halfW = size.x / 2;
4385
+ halfH = size.z / 2;
4386
+ } else {
4387
+ halfW = size.x / 2;
4388
+ halfH = size.y / 2;
4389
+ }
4390
+ var padding = 1.6;
4391
+ var domEl = sv.renderer.domElement;
4392
+ var aspect = domEl.clientWidth / (domEl.clientHeight || 1);
4393
+
4394
+ // Frustum must satisfy both:
4395
+ // frustumHalfW >= halfW * padding (object fits horizontally)
4396
+ // frustumHalfH >= halfH * padding (object fits vertically)
4397
+ // frustumHalfW / frustumHalfH == aspect (no squishing)
4398
+ var minHalfH = halfH * padding;
4399
+ var minHalfW = halfW * padding;
4400
+ var frustumHalfH = Math.max(minHalfH, minHalfW / aspect);
4401
+ var frustumHalfW = frustumHalfH * aspect;
4402
+ var isZFace = Math.abs(faceDir.z) > 0.5;
4403
+ var upVec = isZFace ? new THREE__namespace.Vector3(0, 1, 0) : new THREE__namespace.Vector3(0, 0, 1);
4404
+
4405
+ // Store the perspective camera snapshot on first entry
4406
+ if (!sv._perspCameraSnapshot) {
4407
+ var _sv$controls$target$c, _sv$controls, _sv$controls2, _sv$controls3, _sv$controls4;
4408
+ sv._perspCameraSnapshot = {
4409
+ camera: sv.camera,
4410
+ controlsTarget: (_sv$controls$target$c = (_sv$controls = sv.controls) === null || _sv$controls === void 0 || (_sv$controls = _sv$controls.target) === null || _sv$controls === void 0 ? void 0 : _sv$controls.clone()) !== null && _sv$controls$target$c !== void 0 ? _sv$controls$target$c : new THREE__namespace.Vector3(),
4411
+ controlsMinDist: (_sv$controls2 = sv.controls) === null || _sv$controls2 === void 0 ? void 0 : _sv$controls2.minDistance,
4412
+ controlsMaxDist: (_sv$controls3 = sv.controls) === null || _sv$controls3 === void 0 ? void 0 : _sv$controls3.maxDistance,
4413
+ controlsMaxPolar: (_sv$controls4 = sv.controls) === null || _sv$controls4 === void 0 ? void 0 : _sv$controls4.maxPolarAngle
4414
+ };
4415
+ }
4416
+
4417
+ // Build the orthographic camera
4418
+ var ortho = new THREE__namespace.OrthographicCamera(-frustumHalfW, frustumHalfW, frustumHalfH, -frustumHalfH, 0.01, 1000);
4419
+ ortho.up.copy(upVec);
4420
+ var dist = Math.max(size.x, size.y, size.z) * 3;
4421
+ ortho.position.copy(center).addScaledVector(faceDir, dist);
4422
+ ortho.lookAt(center);
4423
+ // Store half-extents for resize updates (pre-aspect values so resize can recompute)
4424
+ ortho.userData._orthoHalfExtents = {
4425
+ halfW: halfW * padding,
4426
+ halfH: halfH * padding
4427
+ };
4428
+ ortho.updateProjectionMatrix();
4429
+ sv.camera = ortho;
4430
+ _this4.camera = ortho;
4431
+ if (sv.controls) {
4432
+ sv.controls.object = ortho;
4433
+ sv.controls.target.copy(center);
4434
+ sv.controls.minDistance = 0.1;
4435
+ sv.controls.maxDistance = 500;
4436
+ sv.controls.maxPolarAngle = Math.PI;
4437
+ sv.controls.update();
4438
+
4439
+ // Exit ortho mode when the camera actually moves (orbit/pan/zoom).
4440
+ // Using 'change' instead of 'start' so a simple click (e.g. io-device
4441
+ // toggle) never triggers the exit — only real camera movement does.
4442
+ // Skip the first 'change' that fires from controls.update() below.
4443
+ var _skipChanges = 1;
4444
+ var _onOrbitChange = function onOrbitChange() {
4445
+ if (_skipChanges-- > 0) return;
4446
+ sv.controls.removeEventListener('change', _onOrbitChange);
4447
+ _this4._orthoOrbitStartListener = null;
4448
+ _this4._restorePerspectiveCamera();
4449
+ };
4450
+ sv.controls.addEventListener('change', _onOrbitChange);
4451
+ _this4._orthoOrbitStartListener = {
4452
+ controls: sv.controls,
4453
+ fn: _onOrbitChange
4454
+ };
4455
+ }
4456
+ console.log('📐 Switched to orthographic face view:', faceDir);
4457
+ };
4458
+ this.renderer.domElement.addEventListener('dblclick', this.eventHandlers.dblclick);
4459
+ }
4460
+
4461
+ /**
4462
+ * Restore the perspective camera after an orthographic face view.
4463
+ * Safe to call when not in ortho mode (no-op).
4464
+ * @private
4465
+ */
4466
+ }, {
4467
+ key: "_restorePerspectiveCamera",
4468
+ value: function _restorePerspectiveCamera() {
4469
+ var sv = this.sceneViewer;
4470
+ if (!(sv !== null && sv !== void 0 && sv._perspCameraSnapshot)) return;
4471
+
4472
+ // Clean up any pending orbit-change listener
4473
+ if (this._orthoOrbitStartListener) {
4474
+ var _this$_orthoOrbitStar = this._orthoOrbitStartListener,
4475
+ controls = _this$_orthoOrbitStar.controls,
4476
+ fn = _this$_orthoOrbitStar.fn;
4477
+ controls.removeEventListener('change', fn);
4478
+ this._orthoOrbitStartListener = null;
4479
+ }
4480
+ var snap = sv._perspCameraSnapshot;
4481
+ sv.camera = snap.camera;
4482
+ this.camera = snap.camera;
4483
+ if (sv.controls) {
4484
+ sv.controls.object = snap.camera;
4485
+ sv.controls.target.copy(snap.controlsTarget);
4486
+ if (snap.controlsMinDist != null) sv.controls.minDistance = snap.controlsMinDist;
4487
+ if (snap.controlsMaxDist != null) sv.controls.maxDistance = snap.controlsMaxDist;
4488
+ if (snap.controlsMaxPolar != null) sv.controls.maxPolarAngle = snap.controlsMaxPolar;
4489
+ sv.controls.update();
4490
+ }
4491
+ sv._perspCameraSnapshot = null;
4492
+ console.log('📐 Restored perspective camera from ortho face view');
4232
4493
  }
4233
4494
 
4234
4495
  /**
@@ -4378,11 +4639,11 @@ var TransformControlsManager = /*#__PURE__*/function () {
4378
4639
  var isNewSegmentHorizontal = this._isSegmentHorizontal(segment);
4379
4640
 
4380
4641
  // Check if all existing segments have the same orientation
4381
- var _iterator2 = _createForOfIteratorHelper(existingSegments),
4382
- _step2;
4642
+ var _iterator3 = _createForOfIteratorHelper(existingSegments),
4643
+ _step3;
4383
4644
  try {
4384
- for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
4385
- var existingSegment = _step2.value;
4645
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
4646
+ var existingSegment = _step3.value;
4386
4647
  var isExistingHorizontal = this._isSegmentHorizontal(existingSegment);
4387
4648
 
4388
4649
  // Disallow mixing horizontal and vertical
@@ -4391,9 +4652,9 @@ var TransformControlsManager = /*#__PURE__*/function () {
4391
4652
  }
4392
4653
  }
4393
4654
  } catch (err) {
4394
- _iterator2.e(err);
4655
+ _iterator3.e(err);
4395
4656
  } finally {
4396
- _iterator2.f();
4657
+ _iterator3.f();
4397
4658
  }
4398
4659
  return true;
4399
4660
  }
@@ -4845,12 +5106,12 @@ var TransformControlsManager = /*#__PURE__*/function () {
4845
5106
  value: function _applyDeltaToBoundingBoxHelpers() {
4846
5107
  if (!this._dragStartGroupPosition || this.boundingBoxHelpers.length === 0) return;
4847
5108
  var delta = this.multiSelectionGroup.position.clone().sub(this._dragStartGroupPosition);
4848
- var _iterator3 = _createForOfIteratorHelper(this.boundingBoxHelpers),
4849
- _step3;
5109
+ var _iterator4 = _createForOfIteratorHelper(this.boundingBoxHelpers),
5110
+ _step4;
4850
5111
  try {
4851
- for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
5112
+ for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
4852
5113
  var _helper$geometry2;
4853
- var helper = _step3.value;
5114
+ var helper = _step4.value;
4854
5115
  var startPositions = helper.userData._dragStartPositions;
4855
5116
  var posAttr = (_helper$geometry2 = helper.geometry) === null || _helper$geometry2 === void 0 || (_helper$geometry2 = _helper$geometry2.attributes) === null || _helper$geometry2 === void 0 ? void 0 : _helper$geometry2.position;
4856
5117
  if (!startPositions || !posAttr) continue;
@@ -4864,9 +5125,9 @@ var TransformControlsManager = /*#__PURE__*/function () {
4864
5125
  posAttr.needsUpdate = true;
4865
5126
  }
4866
5127
  } catch (err) {
4867
- _iterator3.e(err);
5128
+ _iterator4.e(err);
4868
5129
  } finally {
4869
- _iterator3.f();
5130
+ _iterator4.f();
4870
5131
  }
4871
5132
  }
4872
5133
 
@@ -5244,11 +5505,11 @@ var TransformControlsManager = /*#__PURE__*/function () {
5244
5505
  var objectFilter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
5245
5506
  var objectsWithBounds = this.getSelectableObjectsWithBounds(objectFilter);
5246
5507
  var intersections = [];
5247
- var _iterator4 = _createForOfIteratorHelper(objectsWithBounds),
5248
- _step4;
5508
+ var _iterator5 = _createForOfIteratorHelper(objectsWithBounds),
5509
+ _step5;
5249
5510
  try {
5250
- for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
5251
- var item = _step4.value;
5511
+ for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
5512
+ var item = _step5.value;
5252
5513
  var object = item.object,
5253
5514
  boundingBox = item.boundingBox;
5254
5515
 
@@ -5268,9 +5529,9 @@ var TransformControlsManager = /*#__PURE__*/function () {
5268
5529
 
5269
5530
  // Sort by distance (closest first)
5270
5531
  } catch (err) {
5271
- _iterator4.e(err);
5532
+ _iterator5.e(err);
5272
5533
  } finally {
5273
- _iterator4.f();
5534
+ _iterator5.f();
5274
5535
  }
5275
5536
  intersections.sort(function (a, b) {
5276
5537
  return a.distance - b.distance;
@@ -5656,8 +5917,8 @@ var TransformControlsManager = /*#__PURE__*/function () {
5656
5917
  key: "_updateSegmentReference",
5657
5918
  value: function _updateSegmentReference(oldSegment, newSegment, index) {
5658
5919
  var selectedIndex = this.selectedObjects.findIndex(function (obj) {
5659
- var _obj$userData2;
5660
- return obj.uuid === oldSegment.uuid || ((_obj$userData2 = obj.userData) === null || _obj$userData2 === void 0 ? void 0 : _obj$userData2.originalUuid) === oldSegment.uuid;
5920
+ var _obj$userData3;
5921
+ return obj.uuid === oldSegment.uuid || ((_obj$userData3 = obj.userData) === null || _obj$userData3 === void 0 ? void 0 : _obj$userData3.originalUuid) === oldSegment.uuid;
5661
5922
  });
5662
5923
  if (selectedIndex !== -1 && newSegment) {
5663
5924
  // Clear bounding box cache
@@ -6044,9 +6305,21 @@ var TransformControlsManager = /*#__PURE__*/function () {
6044
6305
  if (this.eventHandlers.keydown) {
6045
6306
  window.removeEventListener('keydown', this.eventHandlers.keydown);
6046
6307
  }
6308
+ if (this.eventHandlers.pointerdown) {
6309
+ this.renderer.domElement.removeEventListener('pointerdown', this.eventHandlers.pointerdown);
6310
+ }
6047
6311
  if (this.eventHandlers.click) {
6048
6312
  this.renderer.domElement.removeEventListener('click', this.eventHandlers.click);
6049
6313
  }
6314
+ if (this.eventHandlers.contextmenu) {
6315
+ this.renderer.domElement.removeEventListener('contextmenu', this.eventHandlers.contextmenu);
6316
+ }
6317
+ if (this.eventHandlers.dblclick) {
6318
+ this.renderer.domElement.removeEventListener('dblclick', this.eventHandlers.dblclick);
6319
+ }
6320
+
6321
+ // Restore perspective camera if disposed while in ortho face view
6322
+ this._restorePerspectiveCamera();
6050
6323
 
6051
6324
  // Remove sceneViewer event listener
6052
6325
  if (this._objectTransformedListener && this.sceneViewer && typeof this.sceneViewer.off === 'function') {
@@ -31463,7 +31736,7 @@ var ModelManager = /*#__PURE__*/function () {
31463
31736
  key: "loadLibraryModel",
31464
31737
  value: function () {
31465
31738
  var _loadLibraryModel = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee(targetMesh, jsonEntry, componentData) {
31466
- var component, _jsonEntry$userData, _jsonEntry$userData2, _jsonEntry$userData3, originalProps, connectorChildren, gltfScene, libraryModel, _this$sceneViewer, ioAnimMgr, _loop, _i, _Object$entries, _jsonEntry$userData4, _t;
31739
+ var component, _jsonEntry$userData, _jsonEntry$userData2, _jsonEntry$userData3, originalProps, connectorChildren, gltfScene, libraryModel, _this$sceneViewer, ioAnimMgr, _loop, _i, _Object$entries, warmFn, _jsonEntry$userData4, _t;
31467
31740
  return _regenerator().w(function (_context2) {
31468
31741
  while (1) switch (_context2.n) {
31469
31742
  case 0:
@@ -31560,6 +31833,20 @@ var ModelManager = /*#__PURE__*/function () {
31560
31833
  case 8:
31561
31834
  // Replace mesh in scene
31562
31835
  this._replaceMeshInScene(targetMesh, libraryModel, originalProps.parent, component);
31836
+
31837
+ // Pre-warm the filtered bounding-box cache for smart components so the
31838
+ // first selection is instant. Deferred to idle time so it does not
31839
+ // block the current frame.
31840
+ if (componentData.isSmart) {
31841
+ warmFn = function warmFn() {
31842
+ return computeFilteredBoundingBoxCached(libraryModel, ['io-device', 'connector']);
31843
+ };
31844
+ if (typeof requestIdleCallback !== 'undefined') {
31845
+ requestIdleCallback(warmFn);
31846
+ } else {
31847
+ setTimeout(warmFn, 0);
31848
+ }
31849
+ }
31563
31850
  console.log("\uD83C\uDF89 ".concat((_jsonEntry$userData3 = jsonEntry.userData) === null || _jsonEntry$userData3 === void 0 ? void 0 : _jsonEntry$userData3.libraryId, " GLB model successfully rendered in scene"));
31564
31851
  return _context2.a(2, libraryModel);
31565
31852
  case 9:
@@ -34791,11 +35078,18 @@ var AnimationManager = /*#__PURE__*/function (_BaseDisposable) {
34791
35078
  sceneViewer.performanceMonitorManager.beginFrame();
34792
35079
  }
34793
35080
  try {
35081
+ var _sceneViewer$managers;
34794
35082
  // Update controls
34795
35083
  sceneViewer.controls.update();
34796
35084
 
34797
- // Render the scene
34798
- sceneViewer.renderer.render(sceneViewer.scene, sceneViewer.camera);
35085
+ // Render the scene — route through the outline manager when active so
35086
+ // the mask pass and screen-space composite run after the main render.
35087
+ var ioOutline = (_sceneViewer$managers = sceneViewer.managers) === null || _sceneViewer$managers === void 0 ? void 0 : _sceneViewer$managers.ioOutlineManager;
35088
+ if (ioOutline !== null && ioOutline !== void 0 && ioOutline.isActive) {
35089
+ ioOutline.render();
35090
+ } else {
35091
+ sceneViewer.renderer.render(sceneViewer.scene, sceneViewer.camera);
35092
+ }
34799
35093
  } catch (renderError) {
34800
35094
  // Catch WebGL or rendering errors to prevent the animation loop from
34801
35095
  // producing a permanent white screen. Log once and continue so that
@@ -37069,6 +37363,169 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37069
37363
  console.log("\uD83D\uDD04 [IODevice] Toggled ".concat(scopedAttachmentId, ".").concat(dpId, ": ").concat(currentVal, " \u2192 ").concat(newVal));
37070
37364
  }
37071
37365
 
37366
+ // ── IO device drag-to-state ─────────────────────────────────────────────
37367
+
37368
+ /**
37369
+ * Begin tracking a drag gesture on an IO device mesh.
37370
+ * Records the initial state of each animation data point so that
37371
+ * `updateIODeviceDrag` can compute relative offsets from it.
37372
+ *
37373
+ * @param {THREE.Object3D} ioDeviceObject
37374
+ */
37375
+ }, {
37376
+ key: "startIODeviceDrag",
37377
+ value: function startIODeviceDrag(ioDeviceObject) {
37378
+ var _this$sceneViewer3,
37379
+ _this2 = this;
37380
+ if (!ioDeviceObject || !this._stateAdapter) return;
37381
+ var ud = ioDeviceObject.userData;
37382
+ var attachmentId = ud === null || ud === void 0 ? void 0 : ud.attachmentId;
37383
+ if (!attachmentId) return;
37384
+ var parentUuid = null;
37385
+ var obj = ioDeviceObject.parent;
37386
+ while (obj) {
37387
+ var _obj$userData2;
37388
+ if (((_obj$userData2 = obj.userData) === null || _obj$userData2 === void 0 ? void 0 : _obj$userData2.objectType) === 'component') {
37389
+ parentUuid = obj.uuid;
37390
+ break;
37391
+ }
37392
+ obj = obj.parent;
37393
+ }
37394
+ var scopedAttachmentId = this._getScopedAttachmentKey(attachmentId, parentUuid);
37395
+ var ioAnimMgr = (_this$sceneViewer3 = this.sceneViewer) === null || _this$sceneViewer3 === void 0 || (_this$sceneViewer3 = _this$sceneViewer3.managers) === null || _this$sceneViewer3 === void 0 ? void 0 : _this$sceneViewer3.ioAnimationManager;
37396
+ var dataPoints = ((ioAnimMgr === null || ioAnimMgr === void 0 ? void 0 : ioAnimMgr.getAnimationDataPoints(parentUuid, attachmentId)) || []).concat((ud === null || ud === void 0 ? void 0 : ud.dataPoints) || [])
37397
+ // deduplicate by id
37398
+ .filter(function (dp, i, arr) {
37399
+ return arr.findIndex(function (d) {
37400
+ return d.id === dp.id;
37401
+ }) === i;
37402
+ });
37403
+ var dpSessions = [];
37404
+ var _iterator = _createForOfIteratorHelper(dataPoints),
37405
+ _step;
37406
+ try {
37407
+ var _loop = function _loop() {
37408
+ var _this2$_stateAdapter$;
37409
+ var dp = _step.value;
37410
+ var stateType = (dp.stateType || '').toLowerCase();
37411
+ if (stateType !== 'binary' && stateType !== 'boolean' && stateType !== 'enum') return 1; // continue
37412
+ var curVal = (_this2$_stateAdapter$ = _this2._stateAdapter.getState(scopedAttachmentId, dp.id)) !== null && _this2$_stateAdapter$ !== void 0 ? _this2$_stateAdapter$ : dp.defaultValue;
37413
+ if (stateType === 'binary' || stateType === 'boolean') {
37414
+ dpSessions.push({
37415
+ dp: dp,
37416
+ scopedAttachmentId: scopedAttachmentId,
37417
+ attachmentId: attachmentId,
37418
+ parentUuid: parentUuid,
37419
+ stateType: 'binary',
37420
+ lastApplied: curVal
37421
+ });
37422
+ } else {
37423
+ var _dp$stateConfig;
37424
+ var opts = ((_dp$stateConfig = dp.stateConfig) === null || _dp$stateConfig === void 0 ? void 0 : _dp$stateConfig.options) || [];
37425
+ var curIdx = opts.findIndex(function (o) {
37426
+ return String(o) === String(curVal);
37427
+ });
37428
+ dpSessions.push({
37429
+ dp: dp,
37430
+ scopedAttachmentId: scopedAttachmentId,
37431
+ attachmentId: attachmentId,
37432
+ parentUuid: parentUuid,
37433
+ stateType: 'enum',
37434
+ opts: opts,
37435
+ startIdx: curIdx >= 0 ? curIdx : 0,
37436
+ lastAppliedIdx: curIdx >= 0 ? curIdx : 0
37437
+ });
37438
+ }
37439
+ };
37440
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
37441
+ if (_loop()) continue;
37442
+ }
37443
+ } catch (err) {
37444
+ _iterator.e(err);
37445
+ } finally {
37446
+ _iterator.f();
37447
+ }
37448
+ this._ioDragSession = dpSessions.length ? {
37449
+ dpSessions: dpSessions
37450
+ } : null;
37451
+ }
37452
+
37453
+ /**
37454
+ * Update animated mesh state while a drag is in progress.
37455
+ * Called continuously during pointermove.
37456
+ *
37457
+ * Sign convention: up/right = positive `signedDelta`.
37458
+ * - Binary: > +20 px → true/on state, < −20 px → false/off state.
37459
+ * - Enum: each ±30 px step advances/retreats one option in the list.
37460
+ *
37461
+ * @param {number} signedDelta - Cumulative signed pixel displacement since drag start
37462
+ */
37463
+ }, {
37464
+ key: "updateIODeviceDrag",
37465
+ value: function updateIODeviceDrag(signedDelta) {
37466
+ var session = this._ioDragSession;
37467
+ if (!session) return;
37468
+ var BINARY_THRESHOLD = 20;
37469
+ var ENUM_STEP_PX = 30;
37470
+ var _iterator2 = _createForOfIteratorHelper(session.dpSessions),
37471
+ _step2;
37472
+ try {
37473
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
37474
+ var dps = _step2.value;
37475
+ if (dps.stateType === 'binary') {
37476
+ var newVal = void 0;
37477
+ if (signedDelta > BINARY_THRESHOLD) {
37478
+ newVal = true;
37479
+ } else if (signedDelta < -BINARY_THRESHOLD) {
37480
+ newVal = false;
37481
+ } else {
37482
+ continue; // dead zone
37483
+ }
37484
+ if (newVal === dps.lastApplied) continue;
37485
+ dps.lastApplied = newVal;
37486
+ this._applyDpState(dps, newVal);
37487
+ } else if (dps.stateType === 'enum') {
37488
+ var steps = Math.round(signedDelta / ENUM_STEP_PX);
37489
+ var newIdx = Math.max(0, Math.min(dps.opts.length - 1, dps.startIdx + steps));
37490
+ if (newIdx === dps.lastAppliedIdx) continue;
37491
+ dps.lastAppliedIdx = newIdx;
37492
+ this._applyDpState(dps, dps.opts[newIdx]);
37493
+ }
37494
+ }
37495
+ } catch (err) {
37496
+ _iterator2.e(err);
37497
+ } finally {
37498
+ _iterator2.f();
37499
+ }
37500
+ }
37501
+
37502
+ /**
37503
+ * Clean up drag session state on pointerup.
37504
+ */
37505
+ }, {
37506
+ key: "endIODeviceDrag",
37507
+ value: function endIODeviceDrag() {
37508
+ this._ioDragSession = null;
37509
+ }
37510
+
37511
+ /**
37512
+ * Apply a new value to a data point, updating Vuex state and firing behavior/animation triggers.
37513
+ * @private
37514
+ */
37515
+ }, {
37516
+ key: "_applyDpState",
37517
+ value: function _applyDpState(_ref2, newVal) {
37518
+ var _this$_stateAdapter, _this$sceneViewer4, _this$sceneViewer5;
37519
+ var scopedAttachmentId = _ref2.scopedAttachmentId,
37520
+ attachmentId = _ref2.attachmentId,
37521
+ parentUuid = _ref2.parentUuid,
37522
+ dp = _ref2.dp;
37523
+ var dpId = dp.id;
37524
+ (_this$_stateAdapter = this._stateAdapter) === null || _this$_stateAdapter === void 0 || _this$_stateAdapter.setState(scopedAttachmentId, dpId, newVal);
37525
+ (_this$sceneViewer4 = this.sceneViewer) === null || _this$sceneViewer4 === void 0 || (_this$sceneViewer4 = _this$sceneViewer4.managers) === null || _this$sceneViewer4 === void 0 || (_this$sceneViewer4 = _this$sceneViewer4.behaviorManager) === null || _this$sceneViewer4 === void 0 || _this$sceneViewer4.triggerState(attachmentId, dpId, newVal, parentUuid);
37526
+ (_this$sceneViewer5 = this.sceneViewer) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.managers) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.ioAnimationManager) === null || _this$sceneViewer5 === void 0 || _this$sceneViewer5.triggerState(attachmentId, dpId, newVal, parentUuid);
37527
+ }
37528
+
37072
37529
  /**
37073
37530
  * Should be called when an object is selected or deselected.
37074
37531
  * @param {THREE.Object3D|null} object
@@ -37182,18 +37639,18 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37182
37639
  }, {
37183
37640
  key: "_getIODevices",
37184
37641
  value: function _getIODevices(object) {
37185
- var _this2 = this;
37642
+ var _this3 = this;
37186
37643
  var devices = [];
37187
37644
  var parentUuid = object.uuid; // The component's own UUID
37188
37645
  object.traverse(function (child) {
37189
37646
  var _child$userData;
37190
37647
  if (((_child$userData = child.userData) === null || _child$userData === void 0 ? void 0 : _child$userData.objectType) === 'io-device') {
37191
- var _this2$sceneViewer$ma, _this2$sceneViewer;
37648
+ var _this3$sceneViewer$ma, _this3$sceneViewer;
37192
37649
  var attachmentId = child.userData.attachmentId || '';
37193
37650
 
37194
37651
  // Use only data points from the animate window (animationConfig).
37195
37652
  // The static ioConfig.states[] snapshot on userData is intentionally ignored.
37196
- var dataPoints = (_this2$sceneViewer$ma = (_this2$sceneViewer = _this2.sceneViewer) === null || _this2$sceneViewer === void 0 || (_this2$sceneViewer = _this2$sceneViewer.managers) === null || _this2$sceneViewer === void 0 || (_this2$sceneViewer = _this2$sceneViewer.ioAnimationManager) === null || _this2$sceneViewer === void 0 ? void 0 : _this2$sceneViewer.getAnimationDataPoints(parentUuid, attachmentId)) !== null && _this2$sceneViewer$ma !== void 0 ? _this2$sceneViewer$ma : [];
37653
+ var dataPoints = (_this3$sceneViewer$ma = (_this3$sceneViewer = _this3.sceneViewer) === null || _this3$sceneViewer === void 0 || (_this3$sceneViewer = _this3$sceneViewer.managers) === null || _this3$sceneViewer === void 0 || (_this3$sceneViewer = _this3$sceneViewer.ioAnimationManager) === null || _this3$sceneViewer === void 0 ? void 0 : _this3$sceneViewer.getAnimationDataPoints(parentUuid, attachmentId)) !== null && _this3$sceneViewer$ma !== void 0 ? _this3$sceneViewer$ma : [];
37197
37654
 
37198
37655
  // When data points come from animationConfig they already carry direction:'input'.
37199
37656
  // Pass null so _buildDataPointRow uses the per-dp direction instead of the
@@ -37203,7 +37660,7 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37203
37660
  label: child.userData.attachmentLabel || child.name || child.userData.deviceId || 'Unknown Device',
37204
37661
  deviceId: child.userData.deviceId || '',
37205
37662
  attachmentId: attachmentId,
37206
- scopedAttachmentId: _this2._getScopedAttachmentKey(attachmentId, parentUuid),
37663
+ scopedAttachmentId: _this3._getScopedAttachmentKey(attachmentId, parentUuid),
37207
37664
  dataPoints: dataPoints,
37208
37665
  direction: deviceDirection
37209
37666
  });
@@ -37219,7 +37676,7 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37219
37676
  }, {
37220
37677
  key: "_buildTooltip",
37221
37678
  value: function _buildTooltip(object) {
37222
- var _this3 = this;
37679
+ var _this4 = this;
37223
37680
  // Remove any existing tooltip first
37224
37681
  this.hide();
37225
37682
  // Re-assign selected object since hide() clears it
@@ -37290,7 +37747,7 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37290
37747
  // Use scopedAttachmentId to ensure state is isolated per component instance
37291
37748
  if (device.scopedAttachmentId && device.dataPoints.length > 0) {
37292
37749
  device.dataPoints.forEach(function (dp) {
37293
- var row = _this3._buildDataPointRow(device.scopedAttachmentId, dp, device.direction, device.attachmentId);
37750
+ var row = _this4._buildDataPointRow(device.scopedAttachmentId, dp, device.direction, device.attachmentId);
37294
37751
  list.appendChild(row);
37295
37752
  });
37296
37753
  }
@@ -37301,11 +37758,11 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37301
37758
  // Hover expand/collapse
37302
37759
  trigger.addEventListener('mouseenter', function () {
37303
37760
  ioSection.classList.add('expanded');
37304
- _this3._ioExpanded = true;
37761
+ _this4._ioExpanded = true;
37305
37762
  });
37306
37763
  ioSection.addEventListener('mouseleave', function () {
37307
37764
  ioSection.classList.remove('expanded');
37308
- _this3._ioExpanded = false;
37765
+ _this4._ioExpanded = false;
37309
37766
  });
37310
37767
  card.appendChild(ioSection);
37311
37768
  } else {
@@ -37349,11 +37806,11 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37349
37806
  }, {
37350
37807
  key: "_positionTooltip",
37351
37808
  value: function _positionTooltip() {
37352
- var _this$sceneViewer3, _this$sceneViewer4;
37809
+ var _this$sceneViewer6, _this$sceneViewer7;
37353
37810
  if (!this.tooltipEl || !this.selectedObject) return;
37354
37811
  var container = this._getContainer();
37355
- var camera = (_this$sceneViewer3 = this.sceneViewer) === null || _this$sceneViewer3 === void 0 ? void 0 : _this$sceneViewer3.camera;
37356
- var renderer = (_this$sceneViewer4 = this.sceneViewer) === null || _this$sceneViewer4 === void 0 ? void 0 : _this$sceneViewer4.renderer;
37812
+ var camera = (_this$sceneViewer6 = this.sceneViewer) === null || _this$sceneViewer6 === void 0 ? void 0 : _this$sceneViewer6.camera;
37813
+ var renderer = (_this$sceneViewer7 = this.sceneViewer) === null || _this$sceneViewer7 === void 0 ? void 0 : _this$sceneViewer7.renderer;
37357
37814
  if (!container || !camera || !renderer) return;
37358
37815
 
37359
37816
  // Compute bounding box to position above the component
@@ -37390,8 +37847,8 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37390
37847
  }, {
37391
37848
  key: "_getContainer",
37392
37849
  value: function _getContainer() {
37393
- var _this$sceneViewer5;
37394
- return ((_this$sceneViewer5 = this.sceneViewer) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.renderer) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.domElement) === null || _this$sceneViewer5 === void 0 ? void 0 : _this$sceneViewer5.parentElement) || null;
37850
+ var _this$sceneViewer8;
37851
+ return ((_this$sceneViewer8 = this.sceneViewer) === null || _this$sceneViewer8 === void 0 || (_this$sceneViewer8 = _this$sceneViewer8.renderer) === null || _this$sceneViewer8 === void 0 || (_this$sceneViewer8 = _this$sceneViewer8.domElement) === null || _this$sceneViewer8 === void 0 ? void 0 : _this$sceneViewer8.parentElement) || null;
37395
37852
  }
37396
37853
 
37397
37854
  // -----------------------------------------------------------------------
@@ -37413,10 +37870,10 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37413
37870
  }, {
37414
37871
  key: "_buildDataPointRow",
37415
37872
  value: function _buildDataPointRow(scopedAttachmentId, dp, deviceDirection, originalAttachmentId) {
37416
- var _ref2,
37873
+ var _ref3,
37417
37874
  _this$_stateAdapter$g,
37418
- _this$_stateAdapter,
37419
- _this4 = this;
37875
+ _this$_stateAdapter2,
37876
+ _this5 = this;
37420
37877
  var row = document.createElement('div');
37421
37878
  row.className = 'cp-tooltip__dp-row';
37422
37879
  var nameEl = document.createElement('span');
@@ -37428,18 +37885,18 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37428
37885
  // Device-level direction takes precedence; fall back to per-dp direction
37429
37886
  var resolvedDirection = deviceDirection || dp.direction || 'output';
37430
37887
  var isInput = resolvedDirection === 'input' || resolvedDirection === 'bidirectional';
37431
- var currentVal = (_ref2 = (_this$_stateAdapter$g = (_this$_stateAdapter = this._stateAdapter) === null || _this$_stateAdapter === void 0 ? void 0 : _this$_stateAdapter.getState(scopedAttachmentId, dpId)) !== null && _this$_stateAdapter$g !== void 0 ? _this$_stateAdapter$g : dp.defaultValue) !== null && _ref2 !== void 0 ? _ref2 : null;
37888
+ var currentVal = (_ref3 = (_this$_stateAdapter$g = (_this$_stateAdapter2 = this._stateAdapter) === null || _this$_stateAdapter2 === void 0 ? void 0 : _this$_stateAdapter2.getState(scopedAttachmentId, dpId)) !== null && _this$_stateAdapter$g !== void 0 ? _this$_stateAdapter$g : dp.defaultValue) !== null && _ref3 !== void 0 ? _ref3 : null;
37432
37889
  if (isInput) {
37433
37890
  var ctrl = this._buildInputControl(dp, currentVal, function (newVal) {
37434
- var _this4$_stateAdapter, _this4$selectedObject, _this4$sceneViewer, _this4$sceneViewer2;
37435
- (_this4$_stateAdapter = _this4._stateAdapter) === null || _this4$_stateAdapter === void 0 || _this4$_stateAdapter.setState(scopedAttachmentId, dpId, newVal);
37891
+ var _this5$_stateAdapter, _this5$selectedObject, _this5$sceneViewer, _this5$sceneViewer2;
37892
+ (_this5$_stateAdapter = _this5._stateAdapter) === null || _this5$_stateAdapter === void 0 || _this5$_stateAdapter.setState(scopedAttachmentId, dpId, newVal);
37436
37893
  // Also fire BehaviorManager so any wired behaviors react immediately.
37437
37894
  // Pass the parent component UUID so behaviors scoped to a specific instance
37438
37895
  // don't bleed across clones that share the same attachmentId.
37439
37896
  // Use originalAttachmentId for behavior triggering as behaviors are keyed by original ID
37440
- var parentUuid = ((_this4$selectedObject = _this4.selectedObject) === null || _this4$selectedObject === void 0 ? void 0 : _this4$selectedObject.uuid) || null;
37441
- (_this4$sceneViewer = _this4.sceneViewer) === null || _this4$sceneViewer === void 0 || (_this4$sceneViewer = _this4$sceneViewer.managers) === null || _this4$sceneViewer === void 0 || (_this4$sceneViewer = _this4$sceneViewer.behaviorManager) === null || _this4$sceneViewer === void 0 || _this4$sceneViewer.triggerState(originalAttachmentId || scopedAttachmentId, dpId, newVal, parentUuid);
37442
- (_this4$sceneViewer2 = _this4.sceneViewer) === null || _this4$sceneViewer2 === void 0 || (_this4$sceneViewer2 = _this4$sceneViewer2.managers) === null || _this4$sceneViewer2 === void 0 || (_this4$sceneViewer2 = _this4$sceneViewer2.ioAnimationManager) === null || _this4$sceneViewer2 === void 0 || _this4$sceneViewer2.triggerState(originalAttachmentId || scopedAttachmentId, dpId, newVal, parentUuid);
37897
+ var parentUuid = ((_this5$selectedObject = _this5.selectedObject) === null || _this5$selectedObject === void 0 ? void 0 : _this5$selectedObject.uuid) || null;
37898
+ (_this5$sceneViewer = _this5.sceneViewer) === null || _this5$sceneViewer === void 0 || (_this5$sceneViewer = _this5$sceneViewer.managers) === null || _this5$sceneViewer === void 0 || (_this5$sceneViewer = _this5$sceneViewer.behaviorManager) === null || _this5$sceneViewer === void 0 || _this5$sceneViewer.triggerState(originalAttachmentId || scopedAttachmentId, dpId, newVal, parentUuid);
37899
+ (_this5$sceneViewer2 = _this5.sceneViewer) === null || _this5$sceneViewer2 === void 0 || (_this5$sceneViewer2 = _this5$sceneViewer2.managers) === null || _this5$sceneViewer2 === void 0 || (_this5$sceneViewer2 = _this5$sceneViewer2.ioAnimationManager) === null || _this5$sceneViewer2 === void 0 || _this5$sceneViewer2.triggerState(originalAttachmentId || scopedAttachmentId, dpId, newVal, parentUuid);
37443
37900
  });
37444
37901
  row.appendChild(ctrl);
37445
37902
  this._stateElements.set(key, {
@@ -37448,9 +37905,9 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37448
37905
  isInput: true
37449
37906
  });
37450
37907
  } else {
37451
- var _dp$stateConfig;
37908
+ var _dp$stateConfig2;
37452
37909
  // unit suffix (optional, shown between name and badge)
37453
- var unit = (_dp$stateConfig = dp.stateConfig) === null || _dp$stateConfig === void 0 ? void 0 : _dp$stateConfig.unit;
37910
+ var unit = (_dp$stateConfig2 = dp.stateConfig) === null || _dp$stateConfig2 === void 0 ? void 0 : _dp$stateConfig2.unit;
37454
37911
  if (unit) {
37455
37912
  var unitEl = document.createElement('span');
37456
37913
  unitEl.className = 'cp-tooltip__dp-unit';
@@ -37595,7 +38052,7 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37595
38052
  }, {
37596
38053
  key: "_refreshStateDisplays",
37597
38054
  value: function _refreshStateDisplays() {
37598
- var _this5 = this;
38055
+ var _this6 = this;
37599
38056
  if (!this._stateAdapter || !this._stateElements.size) return;
37600
38057
  this._stateElements.forEach(function (entry, key) {
37601
38058
  if (entry.isInput) return; // interactive controls are user-driven; don't overwrite
@@ -37604,8 +38061,8 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37604
38061
  if (sepIdx === -1) return;
37605
38062
  var scopedAttachmentId = key.slice(0, sepIdx);
37606
38063
  var dataPointId = key.slice(sepIdx + 2);
37607
- var val = _this5._stateAdapter.getState(scopedAttachmentId, dataPointId);
37608
- _this5._applyBadgeValue(entry.el, val, entry.dp);
38064
+ var val = _this6._stateAdapter.getState(scopedAttachmentId, dataPointId);
38065
+ _this6._applyBadgeValue(entry.el, val, entry.dp);
37609
38066
  });
37610
38067
  }
37611
38068
  }]);
@@ -37828,6 +38285,26 @@ var IoAnimationManager = /*#__PURE__*/function (_BaseDisposable) {
37828
38285
  return dps;
37829
38286
  }
37830
38287
 
38288
+ /**
38289
+ * Return the Three.js mesh objects that are animated for a given attachment.
38290
+ * Used by IoOutlineManager to include animated meshes in the outline.
38291
+ *
38292
+ * @param {string} parentUuid
38293
+ * @param {string} attachmentId
38294
+ * @returns {THREE.Object3D[]}
38295
+ */
38296
+ }, {
38297
+ key: "getAnimatedMeshes",
38298
+ value: function getAnimatedMeshes(parentUuid, attachmentId) {
38299
+ var key = this._key(parentUuid, attachmentId);
38300
+ var entries = this._entries.get(key);
38301
+ if (!(entries !== null && entries !== void 0 && entries.length)) return [];
38302
+ // Deduplicate — multiple animations can target the same mesh
38303
+ return _toConsumableArray(new Set(entries.map(function (e) {
38304
+ return e.mesh;
38305
+ })));
38306
+ }
38307
+
37831
38308
  /**
37832
38309
  * Remove all animation entries associated with a given host component.
37833
38310
  * Call when a component is removed from the scene.
@@ -38059,7 +38536,10 @@ var IoAnimationManager = /*#__PURE__*/function (_BaseDisposable) {
38059
38536
  value: function _applyTranslation(mesh, origPos, transform) {
38060
38537
  var _transform$x, _transform$y, _transform$z;
38061
38538
  if (!transform) return;
38062
- 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));
38539
+ // X and Y are negated to match the sign convention used in the AnimateDevicesDialog
38540
+ // preview (_syncViewerTransform negates x and y before calling setMeshPreviewOffset).
38541
+ // Z is added directly (no negation) — matching the dialog's z handling.
38542
+ 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));
38063
38543
  }
38064
38544
 
38065
38545
  /**
@@ -38130,6 +38610,235 @@ var IoAnimationManager = /*#__PURE__*/function (_BaseDisposable) {
38130
38610
  }]);
38131
38611
  }(BaseDisposable);
38132
38612
 
38613
+ /**
38614
+ * Three.js layer reserved exclusively for the outline mask pass.
38615
+ * Layer 20 is high enough to be well clear of typical app layer usage.
38616
+ */
38617
+ var OUTLINE_LAYER = 20;
38618
+
38619
+ // ── Shaders ──────────────────────────────────────────────────────────────────
38620
+
38621
+ var VERT_SHADER = /* glsl */"\nvarying vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n";
38622
+
38623
+ /**
38624
+ * Screen-space outline shader.
38625
+ *
38626
+ * Samples in a circular kernel to find the nearest filled (silhouette) pixel.
38627
+ * Applies a smooth alpha falloff at the outer edge using smoothstep so the
38628
+ * outline fades cleanly rather than cutting off hard.
38629
+ *
38630
+ * RADIUS — maximum distance in pixels from the silhouette edge to draw.
38631
+ * SAMPLES — kernel half-extent; must be an integer ≥ ceil(RADIUS).
38632
+ */
38633
+ var FRAG_SHADER = /* glsl */"\nuniform sampler2D tMask;\nuniform vec2 uInvSize;\nvarying vec2 vUv;\n\nconst float RADIUS = 3.0; // total outline width in pixels\nconst int SAMPLES = 4; // kernel half-extent (\u2265 ceil(RADIUS))\n\nvoid main() {\n float center = texture2D(tMask, vUv).r;\n\n // Pixels inside the silhouette: the main render already drew them.\n if (center > 0.5) discard;\n\n // Find the closest filled neighbour within the circular kernel.\n float minDist = 99.0;\n for (int x = -SAMPLES; x <= SAMPLES; x++) {\n for (int y = -SAMPLES; y <= SAMPLES; y++) {\n float dist = length(vec2(float(x), float(y)));\n if (dist > RADIUS + 1.0) continue; // skip corners outside circle\n vec2 offset = vec2(float(x), float(y)) * uInvSize;\n if (texture2D(tMask, vUv + offset).r > 0.5) {\n minDist = min(minDist, dist);\n }\n }\n }\n\n if (minDist > RADIUS + 0.5) discard;\n\n // Smooth alpha: full opacity near the edge, fades to 0 at RADIUS pixels out.\n float alpha = 1.0 - smoothstep(RADIUS - 1.0, RADIUS + 0.5, minDist);\n gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);\n}\n";
38634
+ var IoOutlineManager = /*#__PURE__*/function (_BaseDisposable) {
38635
+ function IoOutlineManager(sceneViewer) {
38636
+ var _this;
38637
+ _classCallCheck(this, IoOutlineManager);
38638
+ _this = _callSuper(this, IoOutlineManager);
38639
+ _this.sceneViewer = sceneViewer;
38640
+ _this._maskTarget = null;
38641
+ _this._maskMat = null;
38642
+ _this._overlayScene = null;
38643
+ _this._overlayCamera = null;
38644
+ _this._overlayMat = null;
38645
+
38646
+ /**
38647
+ * Meshes that have been moved onto OUTLINE_LAYER, stored with their
38648
+ * original layers.mask so they can be restored on clearance.
38649
+ * @type {{ mesh: THREE.Mesh, originalMask: number }[]}
38650
+ */
38651
+ _this._layeredMeshes = [];
38652
+
38653
+ /** @type {boolean} Whether an outline is currently active. */
38654
+ _this.isActive = false;
38655
+ return _this;
38656
+ }
38657
+
38658
+ // ─────────────────────────────────────────────────────────────────────────
38659
+ // PUBLIC API
38660
+ // ─────────────────────────────────────────────────────────────────────────
38661
+
38662
+ /**
38663
+ * Set the objects to outline. Pass an empty array (or call with no args)
38664
+ * to remove the outline.
38665
+ * @param {THREE.Object3D[]} objects
38666
+ */
38667
+ _inherits(IoOutlineManager, _BaseDisposable);
38668
+ return _createClass(IoOutlineManager, [{
38669
+ key: "setTargets",
38670
+ value: function setTargets() {
38671
+ var _this2 = this;
38672
+ var objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
38673
+ this._clearLayeredMeshes();
38674
+ if (!objects.length) {
38675
+ this.isActive = false;
38676
+ return;
38677
+ }
38678
+ objects.forEach(function (obj) {
38679
+ obj.traverse(function (child) {
38680
+ if (!child.isMesh) return;
38681
+ var originalMask = child.layers.mask;
38682
+ child.layers.enable(OUTLINE_LAYER);
38683
+ _this2._layeredMeshes.push({
38684
+ mesh: child,
38685
+ originalMask: originalMask
38686
+ });
38687
+ });
38688
+ });
38689
+ this.isActive = this._layeredMeshes.length > 0;
38690
+ }
38691
+
38692
+ /**
38693
+ * Render one frame: main scene → mask pass → outline composite.
38694
+ * Must be called instead of renderer.render() when isActive is true.
38695
+ */
38696
+ }, {
38697
+ key: "render",
38698
+ value: function render() {
38699
+ var sv = this.sceneViewer;
38700
+ var renderer = sv.renderer,
38701
+ scene = sv.scene,
38702
+ camera = sv.camera;
38703
+ if (!renderer || !scene || !camera) return;
38704
+
38705
+ // ── Step 1: Normal scene render — sky, transparency, all unaffected ──
38706
+ renderer.render(scene, camera);
38707
+ if (!this.isActive) return;
38708
+ this._ensureResources();
38709
+ if (!this._maskTarget) return;
38710
+
38711
+ // Stash renderer / scene / camera state we are about to mutate
38712
+ var prevBg = scene.background;
38713
+ var prevOverride = scene.overrideMaterial;
38714
+ var prevLayerMask = camera.layers.mask;
38715
+ var prevAutoClear = renderer.autoClear;
38716
+ var prevClearClr = renderer.getClearColor(new THREE__namespace.Color());
38717
+ var prevClearA = renderer.getClearAlpha();
38718
+
38719
+ // ── Step 2: Render the device silhouette into the private mask target ──
38720
+ scene.background = null; // transparent clear
38721
+ scene.overrideMaterial = this._maskMat; // flat white for all geometry
38722
+ camera.layers.set(OUTLINE_LAYER); // only see the device meshes
38723
+ renderer.setClearColor(0x000000, 0);
38724
+ renderer.autoClear = true;
38725
+ renderer.setRenderTarget(this._maskTarget);
38726
+ renderer.render(scene, camera);
38727
+ renderer.setRenderTarget(null);
38728
+
38729
+ // Restore mutated state
38730
+ scene.background = prevBg;
38731
+ scene.overrideMaterial = prevOverride;
38732
+ camera.layers.mask = prevLayerMask;
38733
+ renderer.setClearColor(prevClearClr, prevClearA);
38734
+
38735
+ // ── Step 3: Composite the outline on top without clearing the framebuffer
38736
+ renderer.autoClear = false;
38737
+ renderer.render(this._overlayScene, this._overlayCamera);
38738
+ renderer.autoClear = prevAutoClear;
38739
+ }
38740
+
38741
+ /**
38742
+ * Call when the canvas is resized to keep the mask target and shader in sync.
38743
+ * @param {number} width
38744
+ * @param {number} height
38745
+ */
38746
+ }, {
38747
+ key: "setSize",
38748
+ value: function setSize(width, height) {
38749
+ if (this._maskTarget) this._maskTarget.setSize(width, height);
38750
+ if (this._overlayMat) {
38751
+ this._overlayMat.uniforms.uInvSize.value.set(1 / width, 1 / height);
38752
+ }
38753
+ }
38754
+ }, {
38755
+ key: "dispose",
38756
+ value: function dispose() {
38757
+ this._clearLayeredMeshes();
38758
+ if (this._maskTarget) {
38759
+ this._maskTarget.dispose();
38760
+ this._maskTarget = null;
38761
+ }
38762
+ if (this._maskMat) {
38763
+ this._maskMat.dispose();
38764
+ this._maskMat = null;
38765
+ }
38766
+ if (this._overlayMat) {
38767
+ this._overlayMat.dispose();
38768
+ this._overlayMat = null;
38769
+ }
38770
+ _superPropGet(IoOutlineManager, "dispose", this, 3)([]);
38771
+ }
38772
+
38773
+ // ─────────────────────────────────────────────────────────────────────────
38774
+ // PRIVATE
38775
+ // ─────────────────────────────────────────────────────────────────────────
38776
+
38777
+ /** Lazy-initialise GPU resources on first use. */
38778
+ }, {
38779
+ key: "_ensureResources",
38780
+ value: function _ensureResources() {
38781
+ if (this._maskTarget) return;
38782
+ var renderer = this.sceneViewer.renderer;
38783
+ if (!renderer) return;
38784
+ var size = renderer.getSize(new THREE__namespace.Vector2());
38785
+ var w = size.x;
38786
+ var h = size.y;
38787
+
38788
+ // Private render target — receives the flat white device silhouette
38789
+ this._maskTarget = new THREE__namespace.WebGLRenderTarget(w, h);
38790
+
38791
+ // Flat white material used during the mask pass
38792
+ this._maskMat = new THREE__namespace.MeshBasicMaterial({
38793
+ color: 0xffffff
38794
+ });
38795
+
38796
+ // Screen-space overlay: reads the mask and draws only the outline pixels
38797
+ this._overlayMat = new THREE__namespace.ShaderMaterial({
38798
+ uniforms: {
38799
+ tMask: {
38800
+ value: this._maskTarget.texture
38801
+ },
38802
+ uInvSize: {
38803
+ value: new THREE__namespace.Vector2(1 / w, 1 / h)
38804
+ }
38805
+ },
38806
+ vertexShader: VERT_SHADER,
38807
+ fragmentShader: FRAG_SHADER,
38808
+ transparent: true,
38809
+ depthTest: false,
38810
+ depthWrite: false
38811
+ });
38812
+ var quad = new THREE__namespace.Mesh(new THREE__namespace.PlaneGeometry(2, 2), this._overlayMat);
38813
+ quad.frustumCulled = false;
38814
+ this._overlayScene = new THREE__namespace.Scene();
38815
+ this._overlayScene.add(quad);
38816
+ this._overlayCamera = new THREE__namespace.OrthographicCamera(-1, 1, 1, -1, 0, 1);
38817
+ }
38818
+
38819
+ /** Remove OUTLINE_LAYER from all tracked meshes and clear the list. */
38820
+ }, {
38821
+ key: "_clearLayeredMeshes",
38822
+ value: function _clearLayeredMeshes() {
38823
+ var _iterator = _createForOfIteratorHelper(this._layeredMeshes),
38824
+ _step;
38825
+ try {
38826
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
38827
+ var _step$value = _step.value,
38828
+ mesh = _step$value.mesh,
38829
+ originalMask = _step$value.originalMask;
38830
+ mesh.layers.mask = originalMask;
38831
+ }
38832
+ } catch (err) {
38833
+ _iterator.e(err);
38834
+ } finally {
38835
+ _iterator.f();
38836
+ }
38837
+ this._layeredMeshes = [];
38838
+ }
38839
+ }]);
38840
+ }(BaseDisposable);
38841
+
38133
38842
  // ─────────────────────────────────────────────────────────────────────────────
38134
38843
  // Flow-direction helpers (module-level)
38135
38844
  // ─────────────────────────────────────────────────────────────────────────────
@@ -38252,6 +38961,7 @@ var CentralPlantInternals = /*#__PURE__*/function () {
38252
38961
  this.centralPlant.managers.componentDragManager = new ComponentDragManager(this.centralPlant.sceneViewer);
38253
38962
  this.centralPlant.managers.viewport2DManager = new Viewport2DManager(this.centralPlant.sceneViewer);
38254
38963
  this.centralPlant.managers.ioAnimationManager = new IoAnimationManager(this.centralPlant.sceneViewer);
38964
+ this.centralPlant.managers.ioOutlineManager = new IoOutlineManager(this.centralPlant.sceneViewer);
38255
38965
 
38256
38966
  // All managers are now stored in the managers collection and will be attached via attachToComponent()
38257
38967
  }
@@ -39268,6 +39978,20 @@ var CentralPlantInternals = /*#__PURE__*/function () {
39268
39978
  componentManager.registerComponent(componentModel);
39269
39979
  }
39270
39980
 
39981
+ // Pre-warm the filtered bounding-box cache for smart components so the
39982
+ // first selection is instant. Deferred to idle time so it does not
39983
+ // block the current frame.
39984
+ if (componentData.isSmart) {
39985
+ var warmFn = function warmFn() {
39986
+ return computeFilteredBoundingBoxCached(componentModel, ['io-device', 'connector']);
39987
+ };
39988
+ if (typeof requestIdleCallback !== 'undefined') {
39989
+ requestIdleCallback(warmFn);
39990
+ } else {
39991
+ setTimeout(warmFn, 0);
39992
+ }
39993
+ }
39994
+
39271
39995
  // EMIT COMPONENT ADDED EVENT
39272
39996
  // This allows UI components (like SceneHierarchy) to update reactively
39273
39997
  if (this.centralPlant.sceneViewer.emit) {
@@ -39547,7 +40271,7 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
39547
40271
  * Initialize the CentralPlant manager
39548
40272
  *
39549
40273
  * @constructor
39550
- * @version 0.3.26
40274
+ * @version 0.3.27
39551
40275
  * @updated 2025-10-22
39552
40276
  *
39553
40277
  * @description Creates a new CentralPlant instance and initializes internal managers and utilities.
@@ -42564,7 +43288,7 @@ var sceneViewer = /*#__PURE__*/function (_BaseDisposable) {
42564
43288
  this.centralPlant.attachToComponent();
42565
43289
 
42566
43290
  // Sync our managers tracking object after attachment
42567
- managerKeys = ['threeJSResourceManager', 'performanceMonitorManager', 'settingsManager', 'sceneExportManager', 'componentManager', 'sceneInitializationManager', 'environmentManager', 'keyboardControlsManager', 'pathfindingManager', 'pathFlowManager', 'behaviorManager', 'ioAnimationManager', 'sceneOperationsManager', 'animationManager', 'cameraControlsManager', 'componentDragManager', 'tooltipsManager', 'componentTooltipManager']; // Populate our managers tracking object
43291
+ managerKeys = ['threeJSResourceManager', 'performanceMonitorManager', 'settingsManager', 'sceneExportManager', 'componentManager', 'sceneInitializationManager', 'environmentManager', 'keyboardControlsManager', 'pathfindingManager', 'pathFlowManager', 'behaviorManager', 'ioAnimationManager', 'ioOutlineManager', 'sceneOperationsManager', 'animationManager', 'cameraControlsManager', 'componentDragManager', 'tooltipsManager', 'componentTooltipManager']; // Populate our managers tracking object
42568
43292
  managerKeys.forEach(function (key) {
42569
43293
  if (_this2[key]) {
42570
43294
  _this2.managers[key] = _this2[key];
@@ -42752,7 +43476,21 @@ var sceneViewer = /*#__PURE__*/function (_BaseDisposable) {
42752
43476
  }
42753
43477
 
42754
43478
  // Update camera aspect ratio
42755
- this.camera.aspect = width / height;
43479
+ if (this.camera.isPerspectiveCamera) {
43480
+ this.camera.aspect = width / height;
43481
+ } else if (this.camera.isOrthographicCamera) {
43482
+ var _this$camera$userData;
43483
+ var aspect = width / height;
43484
+ var extents = (_this$camera$userData = this.camera.userData) === null || _this$camera$userData === void 0 ? void 0 : _this$camera$userData._orthoHalfExtents;
43485
+ if (extents) {
43486
+ var frustumHalfH = Math.max(extents.halfH, extents.halfW / aspect);
43487
+ var frustumHalfW = frustumHalfH * aspect;
43488
+ this.camera.left = -frustumHalfW;
43489
+ this.camera.right = frustumHalfW;
43490
+ this.camera.top = frustumHalfH;
43491
+ this.camera.bottom = -frustumHalfH;
43492
+ }
43493
+ }
42756
43494
  this.camera.updateProjectionMatrix();
42757
43495
 
42758
43496
  // Update renderer size (updateStyle=true to sync canvas CSS)
@@ -42879,6 +43617,45 @@ var sceneViewer = /*#__PURE__*/function (_BaseDisposable) {
42879
43617
  if (_this4.componentTooltipManager) {
42880
43618
  _this4.componentTooltipManager.toggleIODeviceBinaryState(ioDeviceObject);
42881
43619
  }
43620
+ },
43621
+ onIODeviceDrag: function onIODeviceDrag(ioDeviceObject, signedDelta, isStart) {
43622
+ if (isStart) {
43623
+ var _ioDeviceObject$userD, _this4$managers$ioAni, _this4$managers, _this4$managers2;
43624
+ // Resolve parentUuid by walking up to the host component.
43625
+ // Use userData.originalUuid (the custom componentId) because that
43626
+ // is what IoAnimationManager uses as the map key — NOT obj.uuid.
43627
+ var parentUuid = null;
43628
+ var obj = ioDeviceObject.parent;
43629
+ while (obj) {
43630
+ var _obj$userData;
43631
+ if (((_obj$userData = obj.userData) === null || _obj$userData === void 0 ? void 0 : _obj$userData.objectType) === 'component') {
43632
+ parentUuid = obj.userData.originalUuid || obj.uuid;
43633
+ break;
43634
+ }
43635
+ obj = obj.parent;
43636
+ }
43637
+ var attachmentId = (_ioDeviceObject$userD = ioDeviceObject.userData) === null || _ioDeviceObject$userD === void 0 ? void 0 : _ioDeviceObject$userD.attachmentId;
43638
+ // When animated meshes are available, outline ONLY them so their
43639
+ // silhouette is isolated and the outline ring is visible around
43640
+ // them specifically (not swallowed by the larger device body).
43641
+ // Fall back to the whole device group when none are registered.
43642
+ var animatedMeshes = attachmentId && parentUuid ? (_this4$managers$ioAni = (_this4$managers = _this4.managers) === null || _this4$managers === void 0 || (_this4$managers = _this4$managers.ioAnimationManager) === null || _this4$managers === void 0 ? void 0 : _this4$managers.getAnimatedMeshes(parentUuid, attachmentId)) !== null && _this4$managers$ioAni !== void 0 ? _this4$managers$ioAni : [] : [];
43643
+ var targets = animatedMeshes.length > 0 ? animatedMeshes : [ioDeviceObject];
43644
+ (_this4$managers2 = _this4.managers) === null || _this4$managers2 === void 0 || (_this4$managers2 = _this4$managers2.ioOutlineManager) === null || _this4$managers2 === void 0 || _this4$managers2.setTargets(targets);
43645
+ }
43646
+ if (!_this4.componentTooltipManager) return;
43647
+ if (isStart) {
43648
+ _this4.componentTooltipManager.startIODeviceDrag(ioDeviceObject);
43649
+ } else {
43650
+ _this4.componentTooltipManager.updateIODeviceDrag(signedDelta);
43651
+ }
43652
+ },
43653
+ onIODeviceDragEnd: function onIODeviceDragEnd(ioDeviceObject) {
43654
+ var _this4$managers3;
43655
+ (_this4$managers3 = _this4.managers) === null || _this4$managers3 === void 0 || (_this4$managers3 = _this4$managers3.ioOutlineManager) === null || _this4$managers3 === void 0 || _this4$managers3.setTargets([]);
43656
+ if (_this4.componentTooltipManager) {
43657
+ _this4.componentTooltipManager.endIODeviceDrag();
43658
+ }
42882
43659
  }
42883
43660
  });
42884
43661