@2112-lab/central-plant 0.3.57 → 0.3.59

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.
Files changed (29) hide show
  1. package/dist/bundle/index.js +1696 -34
  2. package/dist/cjs/src/core/centralPlant.js +80 -11
  3. package/dist/cjs/src/core/centralPlantInternals.js +4 -0
  4. package/dist/cjs/src/core/centralPlantValidator.js +10 -6
  5. package/dist/cjs/src/core/stateEngine.js +733 -0
  6. package/dist/cjs/src/core/stateEngineScene.js +97 -0
  7. package/dist/cjs/src/index.js +10 -0
  8. package/dist/cjs/src/managers/controls/transformControlsManager.js +61 -2
  9. package/dist/cjs/src/managers/pathfinding/PathFlowManager.js +94 -13
  10. package/dist/cjs/src/managers/pathfinding/PathRenderingManager.js +15 -0
  11. package/dist/cjs/src/managers/scene/componentTooltipManager.js +7 -1
  12. package/dist/cjs/src/managers/scene/controlPanelManager.js +377 -0
  13. package/dist/cjs/src/managers/scene/sceneExportManager.js +10 -1
  14. package/dist/cjs/src/managers/state/EffectiveVisualManager.js +270 -0
  15. package/dist/esm/src/core/centralPlant.js +80 -11
  16. package/dist/esm/src/core/centralPlantInternals.js +4 -0
  17. package/dist/esm/src/core/centralPlantValidator.js +10 -6
  18. package/dist/esm/src/core/stateEngine.js +725 -0
  19. package/dist/esm/src/core/stateEngineScene.js +90 -0
  20. package/dist/esm/src/index.js +2 -0
  21. package/dist/esm/src/managers/controls/transformControlsManager.js +61 -2
  22. package/dist/esm/src/managers/pathfinding/PathFlowManager.js +94 -13
  23. package/dist/esm/src/managers/pathfinding/PathRenderingManager.js +15 -0
  24. package/dist/esm/src/managers/scene/componentTooltipManager.js +7 -1
  25. package/dist/esm/src/managers/scene/controlPanelManager.js +352 -0
  26. package/dist/esm/src/managers/scene/sceneExportManager.js +10 -1
  27. package/dist/esm/src/managers/state/EffectiveVisualManager.js +266 -0
  28. package/dist/index.d.ts +85 -0
  29. package/package.json +5 -2
@@ -2143,20 +2143,24 @@ var CentralPlantValidator = /*#__PURE__*/function () {
2143
2143
  key: "validateImportedSceneData",
2144
2144
  value: function validateImportedSceneData(sceneData) {
2145
2145
  var results = [];
2146
- var hardcodedVersion = "2.3";
2147
2146
 
2148
- // Version validation - MUST be the value of hardcodedVersion
2149
- if (!sceneData.version || sceneData.version !== hardcodedVersion) {
2147
+ // Supported scene formats: 2.3 (legacy I/O behaviors) and 3.0 (State Engine:
2148
+ // adds plant/groups/chains/stateRegistry). Both load; older scenes keep working.
2149
+ var supportedVersions = ["2.3", "3.0"];
2150
+
2151
+ // Version validation - MUST be one of the supported versions
2152
+ if (!sceneData.version || !supportedVersions.includes(sceneData.version)) {
2150
2153
  var providedVersion = sceneData.version || 'unknown';
2151
- alert("The central-plant requires the input file to be version ".concat(hardcodedVersion));
2154
+ var supportedList = supportedVersions.join(' or ');
2155
+ alert("The central-plant requires the input file to be version ".concat(supportedList));
2152
2156
  results.push({
2153
2157
  isValid: false,
2154
2158
  severity: ValidationSeverity.ERROR,
2155
- message: "Invalid version: \"".concat(providedVersion, "\". Required version is \"").concat(hardcodedVersion, "\""),
2159
+ message: "Invalid version: \"".concat(providedVersion, "\". Supported versions are ").concat(supportedList),
2156
2160
  code: 'INVALID_VERSION',
2157
2161
  metadata: {
2158
2162
  providedVersion: providedVersion,
2159
- requiredVersion: hardcodedVersion
2163
+ supportedVersions: supportedVersions
2160
2164
  }
2161
2165
  });
2162
2166
  return this._combineValidationResults(results, 'scene_import_validation');
@@ -4109,6 +4113,12 @@ var TransformControlsManager = /*#__PURE__*/function () {
4109
4113
  // Update bounding box if the transformed object is in current selection
4110
4114
  if (_this.selectedObjects.includes(eventData.object)) {
4111
4115
  console.log('🔲 Updating bounding boxes for selected objects after transform');
4116
+ // Reposition the transform gizmo (and its selection group) to follow the
4117
+ // object's new position. Programmatic transforms (e.g. the Transform tab's
4118
+ // Translate/Rotate buttons) move the object directly without touching the
4119
+ // multi-selection group the gizmo is attached to, so without this the widget
4120
+ // would stay at the pre-transform centroid.
4121
+ _this.syncSelectionToObjects();
4112
4122
  _this.updateBoundingBox();
4113
4123
  }
4114
4124
  };
@@ -5215,6 +5225,59 @@ var TransformControlsManager = /*#__PURE__*/function () {
5215
5225
  console.log("\uD83D\uDCE6 Multi-selection group created at centroid:", centroid.toArray());
5216
5226
  }
5217
5227
 
5228
+ /**
5229
+ * Re-sync the multi-selection group (and the attached transform gizmo) to the
5230
+ * current world positions of the selected objects, in place.
5231
+ *
5232
+ * Used after a *programmatic* transform (e.g. the Transform tab's Translate /
5233
+ * Rotate buttons), which mutates the object directly and does not move the
5234
+ * selection group the gizmo is attached to. Unlike a full updateMultiSelection()
5235
+ * this keeps the same group instance so the gizmo doesn't detach/re-attach.
5236
+ *
5237
+ * It also refreshes the cached original transforms so a subsequent gizmo drag
5238
+ * computes its delta from the object's new position rather than jumping.
5239
+ */
5240
+ }, {
5241
+ key: "syncSelectionToObjects",
5242
+ value: function syncSelectionToObjects() {
5243
+ var _this$transformContro;
5244
+ if (!this.multiSelectionGroup || this.selectedObjects.length === 0) {
5245
+ return;
5246
+ }
5247
+
5248
+ // Recompute the centroid from the objects' current world positions.
5249
+ var centroid = new THREE__namespace.Vector3();
5250
+ this.selectedObjects.forEach(function (obj) {
5251
+ obj.updateMatrixWorld(true);
5252
+ var worldPos = new THREE__namespace.Vector3();
5253
+ obj.getWorldPosition(worldPos);
5254
+ centroid.add(worldPos);
5255
+ });
5256
+ centroid.divideScalar(this.selectedObjects.length);
5257
+
5258
+ // Move the group (and thus the attached gizmo) to the new centroid. Reset any
5259
+ // rotation so the gizmo matches the freshly-created-group convention.
5260
+ this.multiSelectionGroup.position.copy(centroid);
5261
+ this.multiSelectionGroup.rotation.set(0, 0, 0);
5262
+ this.multiSelectionGroup.updateMatrixWorld(true);
5263
+
5264
+ // Refresh cached originals so applyRealtimeTransform's delta stays correct on
5265
+ // the next drag (its "original centroid" is derived from these values).
5266
+ this.selectedObjects.forEach(function (obj) {
5267
+ obj.userData._multiSelectOriginalPosition = obj.position.clone();
5268
+ obj.userData._multiSelectOriginalRotation = obj.rotation.clone();
5269
+ obj.userData._multiSelectOriginalScale = obj.scale.clone();
5270
+ var worldPos = new THREE__namespace.Vector3();
5271
+ obj.getWorldPosition(worldPos);
5272
+ obj.userData._multiSelectOriginalWorldPosition = worldPos;
5273
+ });
5274
+
5275
+ // Keep the gizmo's world matrix in sync with the repositioned group.
5276
+ if ((_this$transformContro = this.transformControls) !== null && _this$transformContro !== void 0 && _this$transformContro.object) {
5277
+ this.transformControls.updateMatrixWorld(true);
5278
+ }
5279
+ }
5280
+
5218
5281
  /**
5219
5282
  * Create a bounding box that encompasses all selected objects
5220
5283
  */
@@ -5494,10 +5557,10 @@ var TransformControlsManager = /*#__PURE__*/function () {
5494
5557
  }, {
5495
5558
  key: "toggleTransformMode",
5496
5559
  value: function toggleTransformMode() {
5497
- var _this$transformContro;
5560
+ var _this$transformContro2;
5498
5561
  var newMode = this.currentMode === 'translate' ? 'rotate' : 'translate';
5499
5562
  this.setMode(newMode);
5500
- if ((_this$transformContro = this.transformControls) !== null && _this$transformContro !== void 0 && _this$transformContro.object) {
5563
+ if ((_this$transformContro2 = this.transformControls) !== null && _this$transformContro2 !== void 0 && _this$transformContro2.object) {
5501
5564
  this.transformControls.updateMatrixWorld(true);
5502
5565
  }
5503
5566
  }
@@ -12055,6 +12118,813 @@ function generateUniqueComponentId(libraryId, scene) {
12055
12118
  return id;
12056
12119
  }
12057
12120
 
12121
+ /**
12122
+ * @file stateEngine.js
12123
+ * @description Portable State Engine core — registry, desired/effective resolution,
12124
+ * plant (and later group) gates, change notifications. No Vue/Three.js.
12125
+ *
12126
+ * Phase 1 scaffold per STATE_ENGINE_FRAMEWORK_0.3.md / milestones doc.
12127
+ */
12128
+
12129
+ /**
12130
+ * @typedef {'desired' | 'effective'} StateLayer
12131
+ */
12132
+
12133
+ /**
12134
+ * Build a scoped registry address.
12135
+ * @param {string} scope - e.g. "plant", "group:chiller-loop-a", "pump-001"
12136
+ * @param {string} attribute
12137
+ * @returns {string}
12138
+ */
12139
+ function makeAddress(scope, attribute) {
12140
+ return "".concat(scope, "::").concat(attribute);
12141
+ }
12142
+
12143
+ /**
12144
+ * Shallow equality for registry values (boolean, number, string, null).
12145
+ * @param {*} a
12146
+ * @param {*} b
12147
+ * @returns {boolean}
12148
+ */
12149
+ function valuesEqual(a, b) {
12150
+ return Object.is(a, b);
12151
+ }
12152
+
12153
+ /**
12154
+ * Safe-off value for a gated operational attribute: 0 for numbers, false for
12155
+ * booleans, otherwise the value unchanged.
12156
+ * @param {*} desired
12157
+ * @returns {*}
12158
+ */
12159
+ function offValue(desired) {
12160
+ if (typeof desired === 'number') return 0;
12161
+ if (typeof desired === 'boolean') return false;
12162
+ return desired;
12163
+ }
12164
+
12165
+ /**
12166
+ * Central Plant State Engine (logic only).
12167
+ *
12168
+ * Desired values are stored in the registry. Effective values are computed
12169
+ * from gates (plant / groups / object) without erasing desired.
12170
+ */
12171
+ var StateEngine = /*#__PURE__*/function () {
12172
+ /**
12173
+ * @param {object} [options]
12174
+ * @param {number} [options.maxChainDepth=8] - Ripple hop limit (Phase 3)
12175
+ */
12176
+ function StateEngine() {
12177
+ var _options$maxChainDept, _options$gatedOperati;
12178
+ var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
12179
+ _classCallCheck(this, StateEngine);
12180
+ /** @type {Map<string, *>} address → desired value */
12181
+ this._desired = new Map();
12182
+
12183
+ /** @type {Map<string, Set<string>>} groupId → member object ids */
12184
+ this._groupMembers = new Map();
12185
+
12186
+ /** @type {Map<string, *>} groupId → group desired power (and later other attrs) */
12187
+ this._groupDesired = new Map();
12188
+
12189
+ /** @type {boolean} */
12190
+ this._plantPowerDesired = true;
12191
+
12192
+ /** @type {Set<(change: { address: string, desired: *, effective: * }) => void>} */
12193
+ this._listeners = new Set();
12194
+
12195
+ /** @type {number} */
12196
+ this.maxChainDepth = (_options$maxChainDept = options.maxChainDepth) !== null && _options$maxChainDept !== void 0 ? _options$maxChainDept : 8;
12197
+
12198
+ /**
12199
+ * Operational attributes that read a safe-off value when their object's
12200
+ * power is not effectively on (Framework §6/§10: fan level 5 + plant off → 0).
12201
+ * Passive/meta attributes stay ungated.
12202
+ * @type {Set<string>}
12203
+ */
12204
+ this._gatedOperationalAttributes = new Set((_options$gatedOperati = options.gatedOperationalAttributes) !== null && _options$gatedOperati !== void 0 ? _options$gatedOperati : ['fan-level', 'rpm', 'level', 'speed']);
12205
+
12206
+ /** @type {Array<object>} declared chains (Phase 3) */
12207
+ this._chains = [];
12208
+
12209
+ /** @type {boolean} */
12210
+ this._batching = false;
12211
+
12212
+ /** @type {Map<string, { address: string, desired: *, effective: * }>} */
12213
+ this._pendingNotifications = new Map();
12214
+ }
12215
+
12216
+ // ─── Load / reset ─────────────────────────────────────────────────────────
12217
+
12218
+ /**
12219
+ * Load plant, groups, and initial desired values from a scene-like declaration.
12220
+ * @param {object} declaration
12221
+ * @param {object} [declaration.plant]
12222
+ * @param {object} [declaration.groups]
12223
+ * @param {object} [declaration.stateRegistry]
12224
+ * @param {Array<object>} [declaration.chains]
12225
+ */
12226
+ return _createClass(StateEngine, [{
12227
+ key: "load",
12228
+ value: function load() {
12229
+ var _declaration$plant;
12230
+ var declaration = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
12231
+ this._desired.clear();
12232
+ this._groupMembers.clear();
12233
+ this._groupDesired.clear();
12234
+ this._chains = Array.isArray(declaration.chains) ? _toConsumableArray(declaration.chains) : [];
12235
+ var plantDefault = ((_declaration$plant = declaration.plant) === null || _declaration$plant === void 0 || (_declaration$plant = _declaration$plant.states) === null || _declaration$plant === void 0 || (_declaration$plant = _declaration$plant.power) === null || _declaration$plant === void 0 ? void 0 : _declaration$plant.default) !== undefined ? declaration.plant.states.power.default : true;
12236
+ this._plantPowerDesired = Boolean(plantDefault);
12237
+ this._desired.set(makeAddress('plant', 'power'), this._plantPowerDesired);
12238
+ var groups = declaration.groups || {};
12239
+ for (var _i = 0, _Object$entries = Object.entries(groups); _i < _Object$entries.length; _i++) {
12240
+ var _group$states;
12241
+ var _Object$entries$_i = _slicedToArray(_Object$entries[_i], 2),
12242
+ groupId = _Object$entries$_i[0],
12243
+ group = _Object$entries$_i[1];
12244
+ var members = Array.isArray(group.members) ? group.members : [];
12245
+ this._groupMembers.set(groupId, new Set(members));
12246
+ var groupPower = ((_group$states = group.states) === null || _group$states === void 0 || (_group$states = _group$states.power) === null || _group$states === void 0 ? void 0 : _group$states.default) !== undefined ? group.states.power.default : true;
12247
+ this._groupDesired.set(groupId, Boolean(groupPower));
12248
+ this._desired.set(makeAddress("group:".concat(groupId), 'power'), Boolean(groupPower));
12249
+ }
12250
+ var registry = declaration.stateRegistry || {};
12251
+ for (var _i2 = 0, _Object$entries2 = Object.entries(registry); _i2 < _Object$entries2.length; _i2++) {
12252
+ var _Object$entries2$_i = _slicedToArray(_Object$entries2[_i2], 2),
12253
+ address = _Object$entries2$_i[0],
12254
+ value = _Object$entries2$_i[1];
12255
+ this._desired.set(address, value);
12256
+ if (address === makeAddress('plant', 'power')) {
12257
+ this._plantPowerDesired = Boolean(value);
12258
+ }
12259
+ var groupMatch = /^group:([^:]+)::power$/.exec(address);
12260
+ if (groupMatch) {
12261
+ this._groupDesired.set(groupMatch[1], Boolean(value));
12262
+ }
12263
+ }
12264
+ }
12265
+ }, {
12266
+ key: "reset",
12267
+ value: function reset() {
12268
+ this._desired.clear();
12269
+ this._groupMembers.clear();
12270
+ this._groupDesired.clear();
12271
+ this._chains = [];
12272
+ this._plantPowerDesired = true;
12273
+ this._pendingNotifications.clear();
12274
+ this._batching = false;
12275
+ }
12276
+
12277
+ // ─── Desired / effective ──────────────────────────────────────────────────
12278
+
12279
+ /**
12280
+ * @param {string} address
12281
+ * @returns {*}
12282
+ */
12283
+ }, {
12284
+ key: "getDesired",
12285
+ value: function getDesired(address) {
12286
+ return this._desired.has(address) ? this._desired.get(address) : undefined;
12287
+ }
12288
+
12289
+ /**
12290
+ * Compute effective value for an address.
12291
+ * Power-style gates: plant AND all containing groups AND object desired.
12292
+ * Non-power attributes: return desired when power gates allow, else a safe off value.
12293
+ *
12294
+ * @param {string} address
12295
+ * @returns {*}
12296
+ */
12297
+ }, {
12298
+ key: "getEffective",
12299
+ value: function getEffective(address) {
12300
+ var desired = this.getDesired(address);
12301
+ if (desired === undefined) return undefined;
12302
+ if (address === makeAddress('plant', 'power')) {
12303
+ return Boolean(desired);
12304
+ }
12305
+ var groupPowerMatch = /^group:([^:]+)::power$/.exec(address);
12306
+ if (groupPowerMatch) {
12307
+ return this._plantPowerDesired && Boolean(desired);
12308
+ }
12309
+ var objectPowerMatch = /^([^:]+)::power$/.exec(address);
12310
+ if (objectPowerMatch && !address.startsWith('group:') && !address.startsWith('plant::')) {
12311
+ var objectId = objectPowerMatch[1];
12312
+ // Skip nested I/O addresses like pump-001::start-switch::mode
12313
+ if (address.split('::').length !== 2) {
12314
+ return desired;
12315
+ }
12316
+ return this._isPowerAllowedForObject(objectId) && Boolean(desired);
12317
+ }
12318
+
12319
+ // Flow and other attrs: when tied to an object id prefix, gate on that object's power path
12320
+ var parts = address.split('::');
12321
+ if (parts.length === 2) {
12322
+ var _parts = _slicedToArray(parts, 2),
12323
+ _objectId = _parts[0],
12324
+ attribute = _parts[1];
12325
+ if (attribute === 'flow') {
12326
+ var powerAllowed = this._isPowerAllowedForObject(_objectId);
12327
+ if (!powerAllowed) return false;
12328
+ return Boolean(desired);
12329
+ }
12330
+ // Operational values (fan level, rpm, …) collapse to a safe-off value when
12331
+ // the object's power is not effectively on; restored when power returns (§6, §10).
12332
+ if (this._gatedOperationalAttributes.has(attribute)) {
12333
+ if (!this._isPowerAllowedForObject(_objectId)) return offValue(desired);
12334
+ return desired;
12335
+ }
12336
+ }
12337
+ return desired;
12338
+ }
12339
+
12340
+ /**
12341
+ * @param {string} objectId
12342
+ * @returns {boolean}
12343
+ */
12344
+ }, {
12345
+ key: "_isPowerAllowedForObject",
12346
+ value: function _isPowerAllowedForObject(objectId) {
12347
+ if (!this._plantPowerDesired) return false;
12348
+ var _iterator = _createForOfIteratorHelper(this._groupMembers),
12349
+ _step;
12350
+ try {
12351
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
12352
+ var _step$value = _slicedToArray(_step.value, 2),
12353
+ groupId = _step$value[0],
12354
+ members = _step$value[1];
12355
+ if (members.has(objectId)) {
12356
+ var groupOn = this._groupDesired.get(groupId);
12357
+ if (!groupOn) return false;
12358
+ }
12359
+ }
12360
+ } catch (err) {
12361
+ _iterator.e(err);
12362
+ } finally {
12363
+ _iterator.f();
12364
+ }
12365
+ return true;
12366
+ }
12367
+
12368
+ /**
12369
+ * Groups that contain an object.
12370
+ * @param {string} objectId
12371
+ * @returns {string[]}
12372
+ */
12373
+ }, {
12374
+ key: "getGroupsForObject",
12375
+ value: function getGroupsForObject(objectId) {
12376
+ var result = [];
12377
+ var _iterator2 = _createForOfIteratorHelper(this._groupMembers),
12378
+ _step2;
12379
+ try {
12380
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
12381
+ var _step2$value = _slicedToArray(_step2.value, 2),
12382
+ groupId = _step2$value[0],
12383
+ members = _step2$value[1];
12384
+ if (members.has(objectId)) result.push(groupId);
12385
+ }
12386
+ } catch (err) {
12387
+ _iterator2.e(err);
12388
+ } finally {
12389
+ _iterator2.f();
12390
+ }
12391
+ return result;
12392
+ }
12393
+
12394
+ // ─── Writes ───────────────────────────────────────────────────────────────
12395
+
12396
+ /**
12397
+ * Set a desired value through the single write door and run one bounded
12398
+ * propagation pass: no-op stop, gate recompute, declared chains with depth
12399
+ * and visited-set guards, then batched notifications (Framework §11, §12).
12400
+ *
12401
+ * @param {string} address
12402
+ * @param {*} value
12403
+ * @returns {boolean} true if the desired value changed
12404
+ */
12405
+ }, {
12406
+ key: "setDesired",
12407
+ value: function setDesired(address, value) {
12408
+ this._beginBatch();
12409
+ var ctx = {
12410
+ visited: new Map()
12411
+ };
12412
+ var changed = this._writeHop(address, value, 0, ctx);
12413
+ this._endBatch();
12414
+ return changed;
12415
+ }
12416
+
12417
+ /**
12418
+ * One hop of a propagation pass: apply the write, then walk declared chains.
12419
+ * @param {string} address
12420
+ * @param {*} value
12421
+ * @param {number} depth
12422
+ * @param {{ visited: Map<string, *> }} ctx
12423
+ * @returns {boolean} whether this hop changed the desired value
12424
+ */
12425
+ }, {
12426
+ key: "_writeHop",
12427
+ value: function _writeHop(address, value, depth, ctx) {
12428
+ var result = this._applyWrite(address, value, ctx);
12429
+ if (!result.changed) return false;
12430
+ this._walkChains(result, depth, ctx);
12431
+ return true;
12432
+ }
12433
+
12434
+ /**
12435
+ * Atomic write door: no-op stop (§11.1), visited-set cycle guard (§11.5),
12436
+ * store desired, update gate mirrors, compute which effectives changed, and
12437
+ * queue notifications for changed addresses only.
12438
+ *
12439
+ * @param {string} address
12440
+ * @param {*} value
12441
+ * @param {{ visited: Map<string, *> }} ctx
12442
+ * @returns {{ changed: boolean, desiredChanged?: string, effectiveChanged?: Set<string> }}
12443
+ */
12444
+ }, {
12445
+ key: "_applyWrite",
12446
+ value: function _applyWrite(address, value, ctx) {
12447
+ var previous = this.getDesired(address);
12448
+ if (previous !== undefined && valuesEqual(previous, value)) {
12449
+ return {
12450
+ changed: false
12451
+ }; // §11.1 no-op stop
12452
+ }
12453
+
12454
+ // §11.5 visited-set: writing the same pair a different value this pass = cycle conflict
12455
+ if (ctx.visited.has(address) && !valuesEqual(ctx.visited.get(address), value)) {
12456
+ console.warn("[StateEngine] cycle conflict: ".concat(address, " rewritten to a different value in one pass; stopping branch"));
12457
+ return {
12458
+ changed: false
12459
+ };
12460
+ }
12461
+ ctx.visited.set(address, value);
12462
+
12463
+ // Snapshot effective for every address this write might gate, before mutating.
12464
+ var affected = this._affectedByWrite(address);
12465
+ var before = new Map();
12466
+ var _iterator3 = _createForOfIteratorHelper(affected),
12467
+ _step3;
12468
+ try {
12469
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
12470
+ var a = _step3.value;
12471
+ before.set(a, this.getEffective(a));
12472
+ }
12473
+
12474
+ // Store desired + keep plant/group gate mirrors in sync.
12475
+ } catch (err) {
12476
+ _iterator3.e(err);
12477
+ } finally {
12478
+ _iterator3.f();
12479
+ }
12480
+ this._desired.set(address, value);
12481
+ if (address === makeAddress('plant', 'power')) {
12482
+ this._plantPowerDesired = Boolean(value);
12483
+ }
12484
+ var groupMatch = /^group:([^:]+)::power$/.exec(address);
12485
+ if (groupMatch) {
12486
+ this._groupDesired.set(groupMatch[1], Boolean(value));
12487
+ }
12488
+
12489
+ // Which effectives actually changed (§11.4 dedupe: notify once, changed only).
12490
+ var effectiveChanged = new Set();
12491
+ var _iterator4 = _createForOfIteratorHelper(affected),
12492
+ _step4;
12493
+ try {
12494
+ for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
12495
+ var _a = _step4.value;
12496
+ if (!valuesEqual(before.get(_a), this.getEffective(_a))) effectiveChanged.add(_a);
12497
+ }
12498
+ } catch (err) {
12499
+ _iterator4.e(err);
12500
+ } finally {
12501
+ _iterator4.f();
12502
+ }
12503
+ this._queueNotification(address); // desired changed
12504
+ var _iterator5 = _createForOfIteratorHelper(effectiveChanged),
12505
+ _step5;
12506
+ try {
12507
+ for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
12508
+ var _a2 = _step5.value;
12509
+ if (_a2 !== address) this._queueNotification(_a2);
12510
+ }
12511
+ } catch (err) {
12512
+ _iterator5.e(err);
12513
+ } finally {
12514
+ _iterator5.f();
12515
+ }
12516
+ return {
12517
+ changed: true,
12518
+ desiredChanged: address,
12519
+ effectiveChanged: effectiveChanged
12520
+ };
12521
+ }
12522
+
12523
+ /**
12524
+ * Addresses whose effective value a write to `address` could change: the
12525
+ * address itself, plus gate dependents (all keys for plant power, member
12526
+ * keys for group power).
12527
+ * @param {string} address
12528
+ * @returns {string[]}
12529
+ */
12530
+ }, {
12531
+ key: "_affectedByWrite",
12532
+ value: function _affectedByWrite(address) {
12533
+ var affected = new Set([address]);
12534
+ if (address === makeAddress('plant', 'power')) {
12535
+ var _iterator6 = _createForOfIteratorHelper(this._desired.keys()),
12536
+ _step6;
12537
+ try {
12538
+ for (_iterator6.s(); !(_step6 = _iterator6.n()).done;) {
12539
+ var key = _step6.value;
12540
+ affected.add(key);
12541
+ }
12542
+ } catch (err) {
12543
+ _iterator6.e(err);
12544
+ } finally {
12545
+ _iterator6.f();
12546
+ }
12547
+ return _toConsumableArray(affected);
12548
+ }
12549
+ var groupMatch = /^group:([^:]+)::power$/.exec(address);
12550
+ if (groupMatch) {
12551
+ var members = this._groupMembers.get(groupMatch[1]);
12552
+ if (members) {
12553
+ var _iterator7 = _createForOfIteratorHelper(members),
12554
+ _step7;
12555
+ try {
12556
+ for (_iterator7.s(); !(_step7 = _iterator7.n()).done;) {
12557
+ var memberId = _step7.value;
12558
+ var _iterator8 = _createForOfIteratorHelper(this._desired.keys()),
12559
+ _step8;
12560
+ try {
12561
+ for (_iterator8.s(); !(_step8 = _iterator8.n()).done;) {
12562
+ var _key = _step8.value;
12563
+ if (_key.startsWith("".concat(memberId, "::"))) affected.add(_key);
12564
+ }
12565
+ } catch (err) {
12566
+ _iterator8.e(err);
12567
+ } finally {
12568
+ _iterator8.f();
12569
+ }
12570
+ }
12571
+ } catch (err) {
12572
+ _iterator7.e(err);
12573
+ } finally {
12574
+ _iterator7.f();
12575
+ }
12576
+ }
12577
+ }
12578
+ return _toConsumableArray(affected);
12579
+ }
12580
+
12581
+ /**
12582
+ * Walk declared chains triggered by this write (§10, §11.3). Desired-source
12583
+ * chains fire when the trigger's desired changed; effective-source chains fire
12584
+ * when the trigger's effective changed (so plant/group-off ripples through
12585
+ * `pump effective → pipe flow`, §17). Only `copy` is supported.
12586
+ *
12587
+ * @param {{ desiredChanged?: string, effectiveChanged?: Set<string> }} result
12588
+ * @param {number} depth
12589
+ * @param {{ visited: Map<string, *> }} ctx
12590
+ */
12591
+ }, {
12592
+ key: "_walkChains",
12593
+ value: function _walkChains(result, depth, ctx) {
12594
+ if (this._chains.length === 0) return;
12595
+ var _iterator9 = _createForOfIteratorHelper(this._chains),
12596
+ _step9;
12597
+ try {
12598
+ for (_iterator9.s(); !(_step9 = _iterator9.n()).done;) {
12599
+ var _result$effectiveChan;
12600
+ var chain = _step9.value;
12601
+ if (chain.action && chain.action !== 'copy') continue;
12602
+ var trigger = makeAddress(chain.when.address, chain.when.attribute);
12603
+ var useEffective = chain.when.source === 'effective';
12604
+ var fires = useEffective ? (_result$effectiveChan = result.effectiveChanged) === null || _result$effectiveChan === void 0 ? void 0 : _result$effectiveChan.has(trigger) : result.desiredChanged === trigger;
12605
+ if (!fires) continue;
12606
+ if (depth + 1 > this.maxChainDepth) {
12607
+ var _chain$id;
12608
+ console.warn("[StateEngine] ripple depth limit (".concat(this.maxChainDepth, ") reached at chain ") + "\"".concat((_chain$id = chain.id) !== null && _chain$id !== void 0 ? _chain$id : '<unnamed>', "\" (").concat(trigger, " \u2192 ").concat(makeAddress(chain.set.address, chain.set.attribute), "); stopping path"));
12609
+ continue;
12610
+ }
12611
+ var nextValue = useEffective ? this.getEffective(trigger) : this.getDesired(trigger);
12612
+ var target = makeAddress(chain.set.address, chain.set.attribute);
12613
+ this._writeHop(target, nextValue, depth + 1, ctx);
12614
+ }
12615
+ } catch (err) {
12616
+ _iterator9.e(err);
12617
+ } finally {
12618
+ _iterator9.f();
12619
+ }
12620
+ }
12621
+
12622
+ // ─── Pipe lifecycle (Framework §9, §15) ─────────────────────────────────────
12623
+
12624
+ /**
12625
+ * Register a pipe/path in the registry so its flow state exists and is tied to
12626
+ * pipe lifecycle (no orphan keys). Keyed on the pipe/path id, never on segment
12627
+ * declared/computed status (§9). No-op if already registered.
12628
+ * @param {string} pipeId
12629
+ * @param {object} [options]
12630
+ * @param {boolean} [options.defaultFlow=false]
12631
+ * @returns {string} the flow address
12632
+ */
12633
+ }, {
12634
+ key: "registerPipe",
12635
+ value: function registerPipe(pipeId) {
12636
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
12637
+ var address = makeAddress(pipeId, 'flow');
12638
+ if (!this._desired.has(address)) {
12639
+ var _options$defaultFlow;
12640
+ this._beginBatch();
12641
+ this._desired.set(address, Boolean((_options$defaultFlow = options.defaultFlow) !== null && _options$defaultFlow !== void 0 ? _options$defaultFlow : false));
12642
+ this._queueNotification(address);
12643
+ this._endBatch();
12644
+ }
12645
+ return address;
12646
+ }
12647
+
12648
+ /**
12649
+ * Remove a pipe and all of its registry entries when the pipe is destroyed, so
12650
+ * keys do not linger (§15). Emits a final notification (desired/effective
12651
+ * undefined) per removed key.
12652
+ * @param {string} pipeId
12653
+ * @returns {number} number of registry keys removed
12654
+ */
12655
+ }, {
12656
+ key: "unregisterPipe",
12657
+ value: function unregisterPipe(pipeId) {
12658
+ var prefix = "".concat(pipeId, "::");
12659
+ var removed = _toConsumableArray(this._desired.keys()).filter(function (k) {
12660
+ return k.startsWith(prefix);
12661
+ });
12662
+ if (removed.length === 0) return 0;
12663
+ this._beginBatch();
12664
+ var _iterator0 = _createForOfIteratorHelper(removed),
12665
+ _step0;
12666
+ try {
12667
+ for (_iterator0.s(); !(_step0 = _iterator0.n()).done;) {
12668
+ var key = _step0.value;
12669
+ this._desired.delete(key);
12670
+ }
12671
+ } catch (err) {
12672
+ _iterator0.e(err);
12673
+ } finally {
12674
+ _iterator0.f();
12675
+ }
12676
+ var _iterator1 = _createForOfIteratorHelper(removed),
12677
+ _step1;
12678
+ try {
12679
+ for (_iterator1.s(); !(_step1 = _iterator1.n()).done;) {
12680
+ var _key2 = _step1.value;
12681
+ this._queueNotification(_key2);
12682
+ }
12683
+ } catch (err) {
12684
+ _iterator1.e(err);
12685
+ } finally {
12686
+ _iterator1.f();
12687
+ }
12688
+ this._endBatch();
12689
+ return removed.length;
12690
+ }
12691
+ }, {
12692
+ key: "_beginBatch",
12693
+ value: function _beginBatch() {
12694
+ this._batching = true;
12695
+ this._pendingNotifications.clear();
12696
+ }
12697
+ }, {
12698
+ key: "_queueNotification",
12699
+ value: function _queueNotification(address) {
12700
+ var change = {
12701
+ address: address,
12702
+ desired: this.getDesired(address),
12703
+ effective: this.getEffective(address)
12704
+ };
12705
+ if (this._batching) {
12706
+ this._pendingNotifications.set(address, change);
12707
+ } else {
12708
+ this._emit(change);
12709
+ }
12710
+ }
12711
+ }, {
12712
+ key: "_endBatch",
12713
+ value: function _endBatch() {
12714
+ this._batching = false;
12715
+ var changes = _toConsumableArray(this._pendingNotifications.values());
12716
+ this._pendingNotifications.clear();
12717
+ var _iterator10 = _createForOfIteratorHelper(changes),
12718
+ _step10;
12719
+ try {
12720
+ for (_iterator10.s(); !(_step10 = _iterator10.n()).done;) {
12721
+ var change = _step10.value;
12722
+ this._emit(change);
12723
+ }
12724
+ } catch (err) {
12725
+ _iterator10.e(err);
12726
+ } finally {
12727
+ _iterator10.f();
12728
+ }
12729
+ }
12730
+ }, {
12731
+ key: "_emit",
12732
+ value: function _emit(change) {
12733
+ var _iterator11 = _createForOfIteratorHelper(this._listeners),
12734
+ _step11;
12735
+ try {
12736
+ for (_iterator11.s(); !(_step11 = _iterator11.n()).done;) {
12737
+ var listener = _step11.value;
12738
+ try {
12739
+ listener(change);
12740
+ } catch (err) {
12741
+ console.error('[StateEngine] listener error:', err);
12742
+ }
12743
+ }
12744
+ } catch (err) {
12745
+ _iterator11.e(err);
12746
+ } finally {
12747
+ _iterator11.f();
12748
+ }
12749
+ }
12750
+
12751
+ /**
12752
+ * @param {(change: { address: string, desired: *, effective: * }) => void} listener
12753
+ * @returns {() => void} unsubscribe
12754
+ */
12755
+ }, {
12756
+ key: "subscribe",
12757
+ value: function subscribe(listener) {
12758
+ var _this = this;
12759
+ this._listeners.add(listener);
12760
+ return function () {
12761
+ return _this._listeners.delete(listener);
12762
+ };
12763
+ }
12764
+
12765
+ /**
12766
+ * Snapshot of all desired values (for save / debug).
12767
+ * @returns {Record<string, *>}
12768
+ */
12769
+ }, {
12770
+ key: "toRegistry",
12771
+ value: function toRegistry() {
12772
+ var out = {};
12773
+ var _iterator12 = _createForOfIteratorHelper(this._desired),
12774
+ _step12;
12775
+ try {
12776
+ for (_iterator12.s(); !(_step12 = _iterator12.n()).done;) {
12777
+ var _step12$value = _slicedToArray(_step12.value, 2),
12778
+ k = _step12$value[0],
12779
+ v = _step12$value[1];
12780
+ out[k] = v;
12781
+ }
12782
+ } catch (err) {
12783
+ _iterator12.e(err);
12784
+ } finally {
12785
+ _iterator12.f();
12786
+ }
12787
+ return out;
12788
+ }
12789
+
12790
+ /**
12791
+ * Reconstruct the §17 scene declaration (plant / groups / chains /
12792
+ * stateRegistry) from current engine state, for scene save. Round-trips with
12793
+ * {@link StateEngine#load}.
12794
+ * @returns {{ version: string, plant: object, groups: object, chains: object[], stateRegistry: Record<string, *> }}
12795
+ */
12796
+ }, {
12797
+ key: "toDeclaration",
12798
+ value: function toDeclaration() {
12799
+ var groups = {};
12800
+ var _iterator13 = _createForOfIteratorHelper(this._groupMembers),
12801
+ _step13;
12802
+ try {
12803
+ for (_iterator13.s(); !(_step13 = _iterator13.n()).done;) {
12804
+ var _this$_groupDesired$g;
12805
+ var _step13$value = _slicedToArray(_step13.value, 2),
12806
+ groupId = _step13$value[0],
12807
+ members = _step13$value[1];
12808
+ groups[groupId] = {
12809
+ members: _toConsumableArray(members),
12810
+ states: {
12811
+ power: {
12812
+ type: 'Boolean',
12813
+ default: (_this$_groupDesired$g = this._groupDesired.get(groupId)) !== null && _this$_groupDesired$g !== void 0 ? _this$_groupDesired$g : true,
12814
+ gate: true
12815
+ }
12816
+ }
12817
+ };
12818
+ }
12819
+ } catch (err) {
12820
+ _iterator13.e(err);
12821
+ } finally {
12822
+ _iterator13.f();
12823
+ }
12824
+ return {
12825
+ version: '3.0',
12826
+ plant: {
12827
+ states: {
12828
+ power: {
12829
+ type: 'Boolean',
12830
+ default: this._plantPowerDesired,
12831
+ gate: true
12832
+ }
12833
+ }
12834
+ },
12835
+ groups: groups,
12836
+ chains: _toConsumableArray(this._chains),
12837
+ stateRegistry: this.toRegistry()
12838
+ };
12839
+ }
12840
+ }]);
12841
+ }();
12842
+
12843
+ /**
12844
+ * Registry address for an I/O device data point.
12845
+ * Shape: `${parentUuid}::${attachmentId}::${stateId}` (matches §17
12846
+ * `pump-001::start-switch::mode`). The scoped key is `${parentUuid}::${attachmentId}`.
12847
+ *
12848
+ * @param {string} attachmentId
12849
+ * @param {string} stateId
12850
+ * @param {string} [parentUuid]
12851
+ * @returns {string}
12852
+ */
12853
+ function ioAddress(attachmentId, stateId, parentUuid) {
12854
+ return makeAddress(getScopedAttachmentKey(attachmentId, parentUuid), stateId);
12855
+ }
12856
+
12857
+ /**
12858
+ * Parse an I/O device address back into its parts. Returns null for
12859
+ * plant/group/object/pipe addresses (2-part) — I/O device data points are 3-part
12860
+ * because a device always has a parent component (§8; free-floating I/O rejected).
12861
+ *
12862
+ * @param {string} address
12863
+ * @returns {{ parentUuid: string, attachmentId: string, stateId: string, scopedKey: string } | null}
12864
+ */
12865
+ function parseIoAddress(address) {
12866
+ if (typeof address !== 'string') return null;
12867
+ var parts = address.split('::');
12868
+ if (parts.length !== 3) return null;
12869
+ var _parts = _slicedToArray(parts, 3),
12870
+ parentUuid = _parts[0],
12871
+ attachmentId = _parts[1],
12872
+ stateId = _parts[2];
12873
+ return {
12874
+ parentUuid: parentUuid,
12875
+ attachmentId: attachmentId,
12876
+ stateId: stateId,
12877
+ scopedKey: "".concat(parentUuid, "::").concat(attachmentId)
12878
+ };
12879
+ }
12880
+
12881
+ /**
12882
+ * Read a State Engine declaration out of scene data, tolerating legacy scenes.
12883
+ * v3.0 scenes carry `plant` / `groups` / `chains` / `stateRegistry`; older v2.3
12884
+ * scenes have none of these and load with safe defaults (plant power on, no
12885
+ * groups, no chains) so nothing crashes on unknown/missing fields (§15).
12886
+ *
12887
+ * @param {object} [sceneData]
12888
+ * @returns {{ plant: object, groups: object, chains: object[], stateRegistry: object }}
12889
+ */
12890
+ function extractDeclaration() {
12891
+ var sceneData = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
12892
+ return {
12893
+ plant: sceneData.plant || {
12894
+ states: {
12895
+ power: {
12896
+ type: 'Boolean',
12897
+ default: true,
12898
+ gate: true
12899
+ }
12900
+ }
12901
+ },
12902
+ groups: sceneData.groups || {},
12903
+ chains: Array.isArray(sceneData.chains) ? sceneData.chains : [],
12904
+ stateRegistry: sceneData.stateRegistry || {}
12905
+ };
12906
+ }
12907
+
12908
+ /**
12909
+ * Merge a State Engine's current declaration (plant / groups / chains /
12910
+ * stateRegistry) into an export object, bumping it to version 3.0. Mutates and
12911
+ * returns `exportData`.
12912
+ *
12913
+ * @param {object} exportData - the scene export object being assembled
12914
+ * @param {{ toDeclaration: () => object }} engine
12915
+ * @returns {object} exportData
12916
+ */
12917
+ function applyDeclarationToExport(exportData, engine) {
12918
+ if (!exportData || !engine || typeof engine.toDeclaration !== 'function') return exportData;
12919
+ var declaration = engine.toDeclaration();
12920
+ exportData.version = '3.0';
12921
+ exportData.plant = declaration.plant;
12922
+ exportData.groups = declaration.groups;
12923
+ if (declaration.chains.length > 0) exportData.chains = declaration.chains;
12924
+ exportData.stateRegistry = declaration.stateRegistry;
12925
+ return exportData;
12926
+ }
12927
+
12058
12928
  var SceneExportManager = /*#__PURE__*/function () {
12059
12929
  function SceneExportManager(sceneViewer) {
12060
12930
  _classCallCheck(this, SceneExportManager);
@@ -12108,7 +12978,8 @@ var SceneExportManager = /*#__PURE__*/function () {
12108
12978
  key: "exportSceneData",
12109
12979
  value: function exportSceneData() {
12110
12980
  var _this = this,
12111
- _this$sceneViewer$cur2;
12981
+ _this$sceneViewer$cur2,
12982
+ _this$sceneViewer$cen;
12112
12983
  console.log('📤 Starting scene export...');
12113
12984
  if (!this.sceneViewer.scene) {
12114
12985
  console.warn('⚠️ No scene available for export');
@@ -12341,6 +13212,13 @@ var SceneExportManager = /*#__PURE__*/function () {
12341
13212
  if (sceneBehaviors && sceneBehaviors.length > 0) {
12342
13213
  exportData.behaviors = sceneBehaviors;
12343
13214
  }
13215
+
13216
+ // Persist plant / groups / chains / stateRegistry from the State Engine and
13217
+ // bump the format to 3.0 (Framework §12; no-op if no engine present).
13218
+ var stateEngine = (_this$sceneViewer$cen = this.sceneViewer.centralPlant) === null || _this$sceneViewer$cen === void 0 ? void 0 : _this$sceneViewer$cen.stateEngine;
13219
+ if (stateEngine) {
13220
+ applyDeclarationToExport(exportData, stateEngine);
13221
+ }
12344
13222
  console.log('✅ Scene export completed:', exportData);
12345
13223
  console.log("\uD83D\uDCCA Exported ".concat(sceneChildren.length, " components and ").concat(exportData.connections.length, " connections"));
12346
13224
  return exportData;
@@ -28180,6 +29058,21 @@ var PathRenderingManager = /*#__PURE__*/function (_BaseDisposable) {
28180
29058
  }
28181
29059
  }
28182
29060
 
29061
+ /**
29062
+ * Read the current color of a path's shared material as a `#rrggbb` string,
29063
+ * or null if the path has no material. Used to snapshot a pipe's baseline
29064
+ * colour before it is temporarily overpainted (e.g. flow gating).
29065
+ *
29066
+ * @param {string} pathId - "${from}-->${to}"
29067
+ * @returns {string|null}
29068
+ */
29069
+ }, {
29070
+ key: "getPathColorHex",
29071
+ value: function getPathColorHex(pathId) {
29072
+ var mat = this._pathMaterials.get(pathId);
29073
+ return mat ? "#".concat(mat.color.getHexString()) : null;
29074
+ }
29075
+
28183
29076
  /**
28184
29077
  * Get path colors for visual distinction
28185
29078
  * @param {number} index - Path index
@@ -30711,6 +31604,9 @@ var TEMP_MIN = 0;
30711
31604
  /** Maximum temperature in the colour ramp (maps to pure red). */
30712
31605
  var TEMP_MAX = 100;
30713
31606
 
31607
+ /** Colour a pipe is painted when its effective flow is gated off (§6.1). */
31608
+ var PIPE_OFF_GRAY = '#3a3a3a';
31609
+
30714
31610
  /**
30715
31611
  * Convert a temperature value to a CSS hex colour string using a blue→red HSL
30716
31612
  * ramp. Values are clamped to [TEMP_MIN, TEMP_MAX].
@@ -30748,22 +31644,78 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
30748
31644
  * @type {Map<string, Record<string, any>>}
30749
31645
  */
30750
31646
  _this._derivedStore = new Map();
31647
+
31648
+ /**
31649
+ * Paths whose effective flow is gated off (State Engine §6.1). While a path
31650
+ * is in this set it is painted {@link PIPE_OFF_GRAY} and temperature updates
31651
+ * are suppressed so they don't override the off-look.
31652
+ * @type {Set<string>}
31653
+ */
31654
+ _this._flowGatedOff = new Set();
31655
+
31656
+ /**
31657
+ * Baseline (on-state) colour snapshotted the moment a path is gated off, so
31658
+ * it can be restored exactly when flow returns — including pipes with no
31659
+ * declared flowTemperature, whose colour is the base pipe material.
31660
+ * @type {Map<string, string>}
31661
+ */
31662
+ _this._flowBaseColor = new Map();
30751
31663
  return _this;
30752
31664
  }
30753
31665
 
30754
- // ── Public API attribute write ──────────────────────────────────────────
31666
+ // ── Flow gating (State Engine effective → 3D bridge, §6.1) ─────────────────
30755
31667
 
30756
31668
  /**
30757
- * Set a declared (persistent) flow attribute on a path.
30758
- * The value is stored in PathData and will be serialised with the scene.
30759
- * Triggers a visual update.
31669
+ * Gate a path's flow visual on/off from the State Engine's effective `flow`.
31670
+ * Off paint the shared per-path material gray; on restore the temperature
31671
+ * colour. The off state is sticky (see {@link _flowGatedOff}).
30760
31672
  *
30761
- * @param {string} pathId - e.g. "PUMP-1-CONN-1-->CHILLER-1-CONN-2"
30762
- * @param {string} key - One of FLOW_ATTRIBUTE_KEYS
30763
- * @param {any} value
31673
+ * @param {string} pathId - `${from}-->${to}`
31674
+ * @param {boolean} enabled - Effective flow
30764
31675
  */
30765
31676
  _inherits(PathFlowManager, _BaseDisposable);
30766
31677
  return _createClass(PathFlowManager, [{
31678
+ key: "setFlowEnabled",
31679
+ value: function setFlowEnabled(pathId, enabled) {
31680
+ var split = this._splitPathId(pathId);
31681
+ if (!split) return;
31682
+ var renderingManager = this._getRenderingManager();
31683
+ if (enabled) {
31684
+ this._flowGatedOff.delete(pathId);
31685
+ // Prefer a temperature-derived colour if one is declared; otherwise
31686
+ // restore the baseline snapshotted when the pipe was gated off (this is
31687
+ // what fixes plain, non-temperature pipes getting stuck gray).
31688
+ var temp = this.resolve(pathId, 'flowTemperature');
31689
+ if (temp !== null && temp !== undefined) {
31690
+ this.applyVisualizationForPath(pathId);
31691
+ } else if (this._flowBaseColor.has(pathId)) {
31692
+ renderingManager === null || renderingManager === void 0 || renderingManager.updatePathColor(split.from, split.to, this._flowBaseColor.get(pathId));
31693
+ }
31694
+ this._flowBaseColor.delete(pathId);
31695
+ } else {
31696
+ // Snapshot the current (on-state) colour once, before overpainting gray.
31697
+ if (!this._flowGatedOff.has(pathId) && renderingManager) {
31698
+ var _renderingManager$get;
31699
+ var base = (_renderingManager$get = renderingManager.getPathColorHex) === null || _renderingManager$get === void 0 ? void 0 : _renderingManager$get.call(renderingManager, pathId);
31700
+ if (base) this._flowBaseColor.set(pathId, base);
31701
+ }
31702
+ this._flowGatedOff.add(pathId);
31703
+ renderingManager === null || renderingManager === void 0 || renderingManager.updatePathColor(split.from, split.to, PIPE_OFF_GRAY);
31704
+ }
31705
+ }
31706
+
31707
+ // ── Public API — attribute write ──────────────────────────────────────────
31708
+
31709
+ /**
31710
+ * Set a declared (persistent) flow attribute on a path.
31711
+ * The value is stored in PathData and will be serialised with the scene.
31712
+ * Triggers a visual update.
31713
+ *
31714
+ * @param {string} pathId - e.g. "PUMP-1-CONN-1-->CHILLER-1-CONN-2"
31715
+ * @param {string} key - One of FLOW_ATTRIBUTE_KEYS
31716
+ * @param {any} value
31717
+ */
31718
+ }, {
30767
31719
  key: "setDeclared",
30768
31720
  value: function setDeclared(pathId, key, value) {
30769
31721
  var pd = this._getPathData(pathId);
@@ -30869,6 +31821,13 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
30869
31821
  }, {
30870
31822
  key: "applyVisualizationForPath",
30871
31823
  value: function applyVisualizationForPath(pathId) {
31824
+ // Effective flow gated off (§6.1) — keep the off-look, ignore temperature.
31825
+ if (this._flowGatedOff.has(pathId)) {
31826
+ var _this$_getRenderingMa;
31827
+ var _split = this._splitPathId(pathId);
31828
+ if (_split) (_this$_getRenderingMa = this._getRenderingManager()) === null || _this$_getRenderingMa === void 0 || _this$_getRenderingMa.updatePathColor(_split.from, _split.to, PIPE_OFF_GRAY);
31829
+ return;
31830
+ }
30872
31831
  var temp = this.resolve(pathId, 'flowTemperature');
30873
31832
  if (temp === null || temp === undefined) {
30874
31833
  return; // No temperature declared — leave default pipe color
@@ -30876,17 +31835,30 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
30876
31835
  var color = temperatureToColor(temp);
30877
31836
  var renderingManager = this._getRenderingManager();
30878
31837
  if (!renderingManager) return;
31838
+ var split = this._splitPathId(pathId);
31839
+ if (!split) return;
31840
+ renderingManager.updatePathColor(split.from, split.to, color);
31841
+ console.log("\uD83C\uDF21\uFE0F PathFlowManager: \"".concat(pathId, "\" \u2192 flowTemperature ").concat(temp, " \u2192 ").concat(color));
31842
+ }
30879
31843
 
30880
- // pathId = "from-->to"; extract the two halves
31844
+ /**
31845
+ * Split a `${from}-->${to}` pathId into its two connector halves.
31846
+ * @param {string} pathId
31847
+ * @returns {{ from: string, to: string }|null} null if malformed
31848
+ * @private
31849
+ */
31850
+ }, {
31851
+ key: "_splitPathId",
31852
+ value: function _splitPathId(pathId) {
30881
31853
  var sepIdx = pathId.indexOf('-->');
30882
31854
  if (sepIdx === -1) {
30883
31855
  console.warn("PathFlowManager: malformed pathId \"".concat(pathId, "\""));
30884
- return;
31856
+ return null;
30885
31857
  }
30886
- var from = pathId.slice(0, sepIdx);
30887
- var to = pathId.slice(sepIdx + 3);
30888
- renderingManager.updatePathColor(from, to, color);
30889
- console.log("\uD83C\uDF21\uFE0F PathFlowManager: \"".concat(pathId, "\" \u2192 flowTemperature ").concat(temp, " \u2192 ").concat(color));
31858
+ return {
31859
+ from: pathId.slice(0, sepIdx),
31860
+ to: pathId.slice(sepIdx + 3)
31861
+ };
30890
31862
  }
30891
31863
 
30892
31864
  /**
@@ -30918,6 +31890,8 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
30918
31890
  key: "dispose",
30919
31891
  value: function dispose() {
30920
31892
  this._derivedStore.clear();
31893
+ this._flowGatedOff.clear();
31894
+ this._flowBaseColor.clear();
30921
31895
  this.sceneViewer = null;
30922
31896
  _superPropGet(PathFlowManager, "dispose", this, 3)([]);
30923
31897
  }
@@ -37840,7 +38814,6 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37840
38814
  if (!ioDeviceObject || !this._stateAdapter) return;
37841
38815
  var ud = ioDeviceObject.userData;
37842
38816
  var attachmentId = ud === null || ud === void 0 ? void 0 : ud.attachmentId;
37843
- var dataPoints = (ud === null || ud === void 0 ? void 0 : ud.dataPoints) || [];
37844
38817
  if (!attachmentId) return;
37845
38818
 
37846
38819
  // Walk up to find parent component UUID for scoped state/behavior handling
@@ -37855,6 +38828,13 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
37855
38828
  obj = obj.parent;
37856
38829
  }
37857
38830
 
38831
+ // Derive the device's data points from its behaviorConfig (same resolver the
38832
+ // drag path uses). `userData.dataPoints` is not populated for attached
38833
+ // devices, so reading it directly would find nothing — resolve instead, and
38834
+ // keep the legacy field only as a fallback.
38835
+ var resolved = resolveDataPoints(parentUuid, attachmentId, ud, getIoBehaviorManager(this.sceneViewer), null);
38836
+ var dataPoints = resolved !== null && resolved !== void 0 && resolved.length ? resolved : (ud === null || ud === void 0 ? void 0 : ud.dataPoints) || [];
38837
+
37858
38838
  // Create a scoped attachment key to prevent state sharing between instances
37859
38839
  // of the same smart component that share the same attachmentId
37860
38840
  var scopedAttachmentId = getScopedAttachmentKey(attachmentId, parentUuid);
@@ -40269,6 +41249,611 @@ var IoOutlineManager = /*#__PURE__*/function (_BaseDisposable) {
40269
41249
  }]);
40270
41250
  }(BaseDisposable);
40271
41251
 
41252
+ /** How far the albedo is pulled toward black for a gated-off mesh. */
41253
+ var OFF_COLOR_SCALE = 0.2;
41254
+ var EffectiveVisualManager = /*#__PURE__*/function (_BaseDisposable) {
41255
+ /**
41256
+ * @param {Object} sceneViewer - The central sceneViewer hub
41257
+ */
41258
+ function EffectiveVisualManager(sceneViewer) {
41259
+ var _this;
41260
+ _classCallCheck(this, EffectiveVisualManager);
41261
+ _this = _callSuper(this, EffectiveVisualManager);
41262
+ _this.sceneViewer = sceneViewer;
41263
+
41264
+ /**
41265
+ * Materials this manager has gated (cloned + cached), for restore on dispose.
41266
+ * @type {Set<THREE.Material>}
41267
+ */
41268
+ _this._gatedMaterials = new Set();
41269
+
41270
+ // Bound handlers so we can unsubscribe in _doDispose().
41271
+ _this._onChange = _this._onChange.bind(_this);
41272
+ _this._regateAll = _this._regateAll.bind(_this);
41273
+ if (sceneViewer !== null && sceneViewer !== void 0 && sceneViewer.on) {
41274
+ sceneViewer.on('engine-state-changed', _this._onChange);
41275
+ sceneViewer.on('scene-loaded', _this._regateAll);
41276
+ }
41277
+ return _this;
41278
+ }
41279
+
41280
+ // ── Engine change handling ──────────────────────────────────────────────────
41281
+
41282
+ /**
41283
+ * React to one committed registry change. Only 2-part object / pipe addresses
41284
+ * are gated; everything else is ignored.
41285
+ * @param {{ address: string, desired: *, effective: * }} change
41286
+ * @private
41287
+ */
41288
+ _inherits(EffectiveVisualManager, _BaseDisposable);
41289
+ return _createClass(EffectiveVisualManager, [{
41290
+ key: "_onChange",
41291
+ value: function _onChange(change) {
41292
+ if (!change || typeof change.address !== 'string') return;
41293
+ var parts = change.address.split('::');
41294
+ if (parts.length !== 2) return; // 3-part I/O is desired-driven; skip
41295
+
41296
+ var _parts = _slicedToArray(parts, 2),
41297
+ scope = _parts[0],
41298
+ attr = _parts[1];
41299
+ if (scope === 'plant' || scope.startsWith('group:')) return; // members self-report
41300
+
41301
+ if (attr === 'power') {
41302
+ this._gateEquipment(scope, change.effective);
41303
+ } else if (attr === 'flow') {
41304
+ this._gatePipe(scope, change.effective);
41305
+ }
41306
+ }
41307
+
41308
+ /**
41309
+ * Re-gate the whole scene from current effective state. Used after scene load,
41310
+ * where StateEngine.load() populates the registry without emitting per-address
41311
+ * events (and the meshes have just been (re)built).
41312
+ * @private
41313
+ */
41314
+ }, {
41315
+ key: "_regateAll",
41316
+ value: function _regateAll() {
41317
+ var _this$sceneViewer;
41318
+ var engine = (_this$sceneViewer = this.sceneViewer) === null || _this$sceneViewer === void 0 || (_this$sceneViewer = _this$sceneViewer.centralPlant) === null || _this$sceneViewer === void 0 ? void 0 : _this$sceneViewer.stateEngine;
41319
+ if (!engine) return;
41320
+ var registry = engine.toRegistry();
41321
+ for (var _i = 0, _Object$keys = Object.keys(registry); _i < _Object$keys.length; _i++) {
41322
+ var address = _Object$keys[_i];
41323
+ var parts = address.split('::');
41324
+ if (parts.length !== 2) continue;
41325
+ var _parts2 = _slicedToArray(parts, 2),
41326
+ scope = _parts2[0],
41327
+ attr = _parts2[1];
41328
+ if (scope === 'plant' || scope.startsWith('group:')) continue;
41329
+ if (attr === 'power') {
41330
+ this._gateEquipment(scope, engine.getEffective(address));
41331
+ } else if (attr === 'flow') {
41332
+ this._gatePipe(scope, engine.getEffective(address));
41333
+ }
41334
+ }
41335
+ }
41336
+
41337
+ // ── Equipment gating ────────────────────────────────────────────────────────
41338
+
41339
+ /**
41340
+ * Darken (off) or restore (on) every mesh of an equipment object.
41341
+ * @param {string} objectId - Hardcoded object id (address scope)
41342
+ * @param {boolean} on - Effective power
41343
+ * @private
41344
+ */
41345
+ }, {
41346
+ key: "_gateEquipment",
41347
+ value: function _gateEquipment(objectId, on) {
41348
+ var _this$sceneViewer2,
41349
+ _this2 = this;
41350
+ var scene = (_this$sceneViewer2 = this.sceneViewer) === null || _this$sceneViewer2 === void 0 ? void 0 : _this$sceneViewer2.scene;
41351
+ if (!scene) return;
41352
+ var obj = findObjectByHardcodedUuid(scene, objectId);
41353
+ if (!obj || typeof obj.traverse !== 'function') return;
41354
+ obj.traverse(function (mesh) {
41355
+ if (!mesh.isMesh && !mesh.material) return;
41356
+ // Leave attached I/O control panels (switches) lit and operable even when
41357
+ // their host is powered off — you must be able to see and click the switch
41358
+ // that turns it back on. (traverse can't prune, so guard per mesh.)
41359
+ if (_this2._isUnderIoDevice(mesh, obj)) return;
41360
+ var materials = _this2._prepareMeshMaterials(mesh);
41361
+ var _iterator = _createForOfIteratorHelper(materials),
41362
+ _step;
41363
+ try {
41364
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
41365
+ var material = _step.value;
41366
+ _this2._applyGate(material, on);
41367
+ }
41368
+ } catch (err) {
41369
+ _iterator.e(err);
41370
+ } finally {
41371
+ _iterator.f();
41372
+ }
41373
+ });
41374
+ }
41375
+
41376
+ /**
41377
+ * True if `mesh` is an io-device or nested under one, walking up to (but not
41378
+ * past) the equipment root `stopAt`.
41379
+ * @param {THREE.Object3D} mesh
41380
+ * @param {THREE.Object3D} stopAt - equipment root to stop the upward walk at
41381
+ * @returns {boolean}
41382
+ * @private
41383
+ */
41384
+ }, {
41385
+ key: "_isUnderIoDevice",
41386
+ value: function _isUnderIoDevice(mesh, stopAt) {
41387
+ var node = mesh;
41388
+ while (node) {
41389
+ var _node$userData;
41390
+ if (((_node$userData = node.userData) === null || _node$userData === void 0 ? void 0 : _node$userData.objectType) === 'io-device') return true;
41391
+ if (node === stopAt) break;
41392
+ node = node.parent;
41393
+ }
41394
+ return false;
41395
+ }
41396
+
41397
+ /**
41398
+ * Ensure a mesh's material(s) are cloned-on-write and have their baseline
41399
+ * cached, then return the material list to gate.
41400
+ * @param {THREE.Mesh} mesh
41401
+ * @returns {THREE.Material[]}
41402
+ * @private
41403
+ */
41404
+ }, {
41405
+ key: "_prepareMeshMaterials",
41406
+ value: function _prepareMeshMaterials(mesh) {
41407
+ if (!mesh.material) return [];
41408
+
41409
+ // Clone-on-write once per mesh so shared library materials aren't shared.
41410
+ if (!mesh.userData._effectiveGateCloned) {
41411
+ if (Array.isArray(mesh.material)) {
41412
+ mesh.material = mesh.material.map(function (m) {
41413
+ return m !== null && m !== void 0 && m.clone ? m.clone() : m;
41414
+ });
41415
+ } else if (mesh.material.clone) {
41416
+ mesh.material = mesh.material.clone();
41417
+ }
41418
+ mesh.userData._effectiveGateCloned = true;
41419
+ }
41420
+ var materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
41421
+ var _iterator2 = _createForOfIteratorHelper(materials),
41422
+ _step2;
41423
+ try {
41424
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
41425
+ var _material$userData, _material$color$clone, _material$color, _material$color$clone2, _material$emissive$cl, _material$emissive, _material$emissive$cl2;
41426
+ var material = _step2.value;
41427
+ if (!material || (_material$userData = material.userData) !== null && _material$userData !== void 0 && _material$userData._effectiveGate) continue;
41428
+ material.userData = material.userData || {};
41429
+ material.userData._effectiveGate = {
41430
+ color: (_material$color$clone = (_material$color = material.color) === null || _material$color === void 0 || (_material$color$clone2 = _material$color.clone) === null || _material$color$clone2 === void 0 ? void 0 : _material$color$clone2.call(_material$color)) !== null && _material$color$clone !== void 0 ? _material$color$clone : null,
41431
+ emissive: (_material$emissive$cl = (_material$emissive = material.emissive) === null || _material$emissive === void 0 || (_material$emissive$cl2 = _material$emissive.clone) === null || _material$emissive$cl2 === void 0 ? void 0 : _material$emissive$cl2.call(_material$emissive)) !== null && _material$emissive$cl !== void 0 ? _material$emissive$cl : null,
41432
+ emissiveIntensity: typeof material.emissiveIntensity === 'number' ? material.emissiveIntensity : null
41433
+ };
41434
+ this._gatedMaterials.add(material);
41435
+ }
41436
+ } catch (err) {
41437
+ _iterator2.e(err);
41438
+ } finally {
41439
+ _iterator2.f();
41440
+ }
41441
+ return materials.filter(Boolean);
41442
+ }
41443
+
41444
+ /**
41445
+ * Darken or restore a single material from its cached baseline.
41446
+ * @param {THREE.Material} material
41447
+ * @param {boolean} on
41448
+ * @private
41449
+ */
41450
+ }, {
41451
+ key: "_applyGate",
41452
+ value: function _applyGate(material, on) {
41453
+ var _material$userData2;
41454
+ var base = (_material$userData2 = material.userData) === null || _material$userData2 === void 0 ? void 0 : _material$userData2._effectiveGate;
41455
+ if (!base) return;
41456
+ if (on) {
41457
+ if (base.color && material.color) material.color.copy(base.color);
41458
+ if (base.emissive && material.emissive) material.emissive.copy(base.emissive);
41459
+ if (base.emissiveIntensity !== null) material.emissiveIntensity = base.emissiveIntensity;
41460
+ } else {
41461
+ if (base.color && material.color) material.color.copy(base.color).multiplyScalar(OFF_COLOR_SCALE);
41462
+ if (material.emissive) material.emissive.setScalar(0);
41463
+ if (typeof material.emissiveIntensity === 'number') material.emissiveIntensity = 0;
41464
+ }
41465
+ material.needsUpdate = true;
41466
+ }
41467
+
41468
+ // ── Pipe flow gating ────────────────────────────────────────────────────────
41469
+
41470
+ /**
41471
+ * Delegate pipe-flow gating to PathFlowManager (grays the shared per-path
41472
+ * material when off, restores the temperature colour when on).
41473
+ * @param {string} pathId - `${from}-->${to}`
41474
+ * @param {boolean} on - Effective flow
41475
+ * @private
41476
+ */
41477
+ }, {
41478
+ key: "_gatePipe",
41479
+ value: function _gatePipe(pathId, on) {
41480
+ var _this$sceneViewer3, _pathFlowManager$setF;
41481
+ var pathFlowManager = (_this$sceneViewer3 = this.sceneViewer) === null || _this$sceneViewer3 === void 0 || (_this$sceneViewer3 = _this$sceneViewer3.managers) === null || _this$sceneViewer3 === void 0 ? void 0 : _this$sceneViewer3.pathFlowManager;
41482
+ pathFlowManager === null || pathFlowManager === void 0 || (_pathFlowManager$setF = pathFlowManager.setFlowEnabled) === null || _pathFlowManager$setF === void 0 || _pathFlowManager$setF.call(pathFlowManager, pathId, on);
41483
+ }
41484
+
41485
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
41486
+ }, {
41487
+ key: "_doDispose",
41488
+ value: function _doDispose() {
41489
+ var _this$sceneViewer4;
41490
+ if ((_this$sceneViewer4 = this.sceneViewer) !== null && _this$sceneViewer4 !== void 0 && _this$sceneViewer4.off) {
41491
+ this.sceneViewer.off('engine-state-changed', this._onChange);
41492
+ this.sceneViewer.off('scene-loaded', this._regateAll);
41493
+ }
41494
+ // Restore any still-gated materials to their cached baseline.
41495
+ var _iterator3 = _createForOfIteratorHelper(this._gatedMaterials),
41496
+ _step3;
41497
+ try {
41498
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
41499
+ var material = _step3.value;
41500
+ this._applyGate(material, true);
41501
+ }
41502
+ } catch (err) {
41503
+ _iterator3.e(err);
41504
+ } finally {
41505
+ _iterator3.f();
41506
+ }
41507
+ this._gatedMaterials.clear();
41508
+ this.sceneViewer = null;
41509
+ }
41510
+ }]);
41511
+ }(BaseDisposable);
41512
+
41513
+ /**
41514
+ * X offsets for `count` switches evenly spaced and centered on 0.
41515
+ * @param {number} count
41516
+ * @param {number} spacing
41517
+ * @returns {number[]}
41518
+ */
41519
+ function computeSwitchRowX(count, spacing) {
41520
+ var start = -((count - 1) * spacing) / 2;
41521
+ return Array.from({
41522
+ length: count
41523
+ }, function (_, i) {
41524
+ return start + i * spacing;
41525
+ });
41526
+ }
41527
+ var ControlPanelManager = /*#__PURE__*/function (_BaseDisposable) {
41528
+ /**
41529
+ * @param {Object} sceneViewer - The central sceneViewer hub
41530
+ */
41531
+ function ControlPanelManager(sceneViewer) {
41532
+ var _this;
41533
+ _classCallCheck(this, ControlPanelManager);
41534
+ _this = _callSuper(this, ControlPanelManager);
41535
+ _this.sceneViewer = sceneViewer;
41536
+ _this._panel = null;
41537
+ _this._labels = [];
41538
+ _this._panelId = null;
41539
+ _this._build = _this._build.bind(_this);
41540
+ if (sceneViewer !== null && sceneViewer !== void 0 && sceneViewer.on) {
41541
+ sceneViewer.on('scene-loaded', _this._build);
41542
+ }
41543
+ return _this;
41544
+ }
41545
+
41546
+ /**
41547
+ * Build (or rebuild) the panel from the current scene's `controlPanel` spec.
41548
+ * @private
41549
+ */
41550
+ _inherits(ControlPanelManager, _BaseDisposable);
41551
+ return _createClass(ControlPanelManager, [{
41552
+ key: "_build",
41553
+ value: (function () {
41554
+ var _build2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
41555
+ var _this$sceneViewer,
41556
+ _centralPlant$importe,
41557
+ _this$sceneViewer2,
41558
+ _b$width,
41559
+ _b$height,
41560
+ _b$thickness,
41561
+ _desk$tilt,
41562
+ _desk$height,
41563
+ _desk$legThickness,
41564
+ _desk$legInset,
41565
+ _spec$switchSpacing,
41566
+ _this$sceneViewer3,
41567
+ _spec$labelSize,
41568
+ _this2 = this;
41569
+ var centralPlant, spec, scene, panelId, panel, p, r, d2r, scale, b, width, depth, thickness, desk, tilt, deskHeight, legThickness, legInset, legColor, top, board, legMat, halfW, halfD, _i, _arr, ly, cornerZ, legH, yPos, _i2, _arr2, lx, leg, switches, spacing, xs, topZ, attachedDevices, componentData, switchScale, ioBehavMgr, engine, _iterator, _step, sw, current, lo, labelSize;
41570
+ return _regenerator().w(function (_context) {
41571
+ while (1) switch (_context.n) {
41572
+ case 0:
41573
+ this._teardown();
41574
+ centralPlant = (_this$sceneViewer = this.sceneViewer) === null || _this$sceneViewer === void 0 ? void 0 : _this$sceneViewer.centralPlant;
41575
+ spec = centralPlant === null || centralPlant === void 0 || (_centralPlant$importe = centralPlant.importedSceneData) === null || _centralPlant$importe === void 0 ? void 0 : _centralPlant$importe.controlPanel;
41576
+ scene = (_this$sceneViewer2 = this.sceneViewer) === null || _this$sceneViewer2 === void 0 ? void 0 : _this$sceneViewer2.scene;
41577
+ if (!(!spec || !scene)) {
41578
+ _context.n = 1;
41579
+ break;
41580
+ }
41581
+ return _context.a(2);
41582
+ case 1:
41583
+ panelId = spec.id || 'CONTROL-PANEL-1';
41584
+ this._panelId = panelId;
41585
+ panel = new THREE__namespace.Group();
41586
+ panel.name = 'Control Panel';
41587
+ panel.uuid = panelId;
41588
+ panel.userData = {
41589
+ objectType: 'component',
41590
+ isControlPanel: true
41591
+ };
41592
+ p = spec.position || {};
41593
+ panel.position.set(p.x || 0, p.y || 0, p.z || 0);
41594
+ r = spec.rotation || {};
41595
+ d2r = Math.PI / 180;
41596
+ panel.rotation.set((r.x || 0) * d2r, (r.y || 0) * d2r, (r.z || 0) * d2r, 'XYZ');
41597
+ scale = Number.isFinite(spec.scale) && spec.scale > 0 ? spec.scale : 1;
41598
+ panel.scale.setScalar(scale);
41599
+
41600
+ // ── Drawing-desk geometry (Z-up scene) ───────────────────────────────────
41601
+ // A tilted top surface (the drafting board) raised on legs. Switches +
41602
+ // labels live on the tilted `top` group so they sit on the angled surface.
41603
+ b = spec.board || {};
41604
+ width = (_b$width = b.width) !== null && _b$width !== void 0 ? _b$width : 7; // x-extent of the top
41605
+ depth = (_b$height = b.height) !== null && _b$height !== void 0 ? _b$height : 2; // y-extent of the top (spec key kept as `height`)
41606
+ thickness = (_b$thickness = b.thickness) !== null && _b$thickness !== void 0 ? _b$thickness : 0.15;
41607
+ desk = spec.desk || {};
41608
+ tilt = ((_desk$tilt = desk.tilt) !== null && _desk$tilt !== void 0 ? _desk$tilt : 30) * d2r; // surface tilt; +raises the back (+y) edge
41609
+ deskHeight = (_desk$height = desk.height) !== null && _desk$height !== void 0 ? _desk$height : 1.3; // world height of the top's center
41610
+ legThickness = (_desk$legThickness = desk.legThickness) !== null && _desk$legThickness !== void 0 ? _desk$legThickness : 0.18;
41611
+ legInset = (_desk$legInset = desk.legInset) !== null && _desk$legInset !== void 0 ? _desk$legInset : 0.35;
41612
+ legColor = desk.legColor || '#22303c'; // Tilted top group: raised to deskHeight, rotated about X.
41613
+ top = new THREE__namespace.Group();
41614
+ top.position.set(0, 0, deskHeight);
41615
+ top.rotation.set(tilt, 0, 0, 'XYZ');
41616
+ panel.add(top);
41617
+ board = new THREE__namespace.Mesh(new THREE__namespace.BoxGeometry(width, depth, thickness), new THREE__namespace.MeshStandardMaterial({
41618
+ color: b.color || '#2c3e50',
41619
+ roughness: 0.6,
41620
+ metalness: 0.2
41621
+ }));
41622
+ board.userData = {
41623
+ objectType: 'control-panel-board'
41624
+ };
41625
+ top.add(board);
41626
+
41627
+ // Legs: one per corner, from the ground up to the tilted top's underside.
41628
+ legMat = new THREE__namespace.MeshStandardMaterial({
41629
+ color: legColor,
41630
+ roughness: 0.7,
41631
+ metalness: 0.1
41632
+ });
41633
+ halfW = Math.max(0.1, width / 2 - legInset);
41634
+ halfD = Math.max(0.1, depth / 2 - legInset);
41635
+ for (_i = 0, _arr = [halfD, -halfD]; _i < _arr.length; _i++) {
41636
+ ly = _arr[_i];
41637
+ // Underside height at this front/back row after the tilt.
41638
+ cornerZ = deskHeight + ly * Math.sin(tilt) - thickness / 2 * Math.cos(tilt);
41639
+ legH = Math.max(0.1, cornerZ);
41640
+ yPos = ly * Math.cos(tilt); // corner's y in panel space after tilt
41641
+ for (_i2 = 0, _arr2 = [halfW, -halfW]; _i2 < _arr2.length; _i2++) {
41642
+ lx = _arr2[_i2];
41643
+ leg = new THREE__namespace.Mesh(new THREE__namespace.BoxGeometry(legThickness, legThickness, legH), legMat);
41644
+ leg.position.set(lx, yPos, legH / 2);
41645
+ panel.add(leg);
41646
+ }
41647
+ }
41648
+
41649
+ // ── Switch layout: a row along X on the top face (+Z) of the board ───────
41650
+ switches = Array.isArray(spec.switches) ? spec.switches : [];
41651
+ spacing = (_spec$switchSpacing = spec.switchSpacing) !== null && _spec$switchSpacing !== void 0 ? _spec$switchSpacing : 1.3;
41652
+ xs = computeSwitchRowX(switches.length, spacing);
41653
+ topZ = thickness / 2;
41654
+ attachedDevices = {};
41655
+ switches.forEach(function (sw, i) {
41656
+ attachedDevices[sw.attachmentId] = {
41657
+ deviceId: spec.deviceId,
41658
+ attachmentLabel: sw.label || sw.attachmentId,
41659
+ attachmentPoint: {
41660
+ position: {
41661
+ x: xs[i],
41662
+ y: 0,
41663
+ z: topZ
41664
+ },
41665
+ rotation: spec.switchRotation || {
41666
+ x: 0,
41667
+ y: 0,
41668
+ z: 0
41669
+ }
41670
+ }
41671
+ };
41672
+ });
41673
+ componentData = {
41674
+ isSmart: true,
41675
+ attachedDevices: attachedDevices
41676
+ }; // Clone + tag the switch devices onto the tilted top (reuses smart-component path).
41677
+ _context.n = 2;
41678
+ return attachIODevicesToComponent(top, componentData, modelPreloader, panelId);
41679
+ case 2:
41680
+ // Optional switch-only scale (attach path always leaves devices at 1:1).
41681
+ switchScale = Number.isFinite(spec.switchScale) && spec.switchScale > 0 ? spec.switchScale : 1;
41682
+ if (switchScale !== 1) {
41683
+ top.traverse(function (obj) {
41684
+ var _obj$userData;
41685
+ if (((_obj$userData = obj.userData) === null || _obj$userData === void 0 ? void 0 : _obj$userData.objectType) === 'io-device') {
41686
+ obj.scale.setScalar(switchScale);
41687
+ }
41688
+ });
41689
+ }
41690
+
41691
+ // Panel must be in the scene before behaviors register (default-state seeding
41692
+ // scans the live scene graph for io-devices).
41693
+ scene.add(panel);
41694
+ this._panel = panel;
41695
+ ioBehavMgr = (_this$sceneViewer3 = this.sceneViewer) === null || _this$sceneViewer3 === void 0 || (_this$sceneViewer3 = _this$sceneViewer3.managers) === null || _this$sceneViewer3 === void 0 ? void 0 : _this$sceneViewer3.ioBehaviorManager;
41696
+ if (ioBehavMgr) {
41697
+ registerBehaviorsForComponent(ioBehavMgr, panelId, componentData, panel, modelPreloader.componentDictionary, centralPlant);
41698
+
41699
+ // Drive each switch's mesh to its current engine value so the initial
41700
+ // pose/colour reflects the state. `registerBehaviorsForComponent`'s
41701
+ // default-seeding writes through the engine, which no-ops when the value
41702
+ // already matches the seeded `stateRegistry` — leaving the button at its
41703
+ // neutral GLB rest pose. Trigger the animation directly to avoid that.
41704
+ engine = centralPlant === null || centralPlant === void 0 ? void 0 : centralPlant.stateEngine;
41705
+ _iterator = _createForOfIteratorHelper(switches);
41706
+ try {
41707
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
41708
+ sw = _step.value;
41709
+ current = engine === null || engine === void 0 ? void 0 : engine.getDesired("".concat(panelId, "::").concat(sw.attachmentId, "::mode"));
41710
+ ioBehavMgr.triggerState(sw.attachmentId, 'mode', current !== null && current !== void 0 ? current : true, panelId);
41711
+ }
41712
+ } catch (err) {
41713
+ _iterator.e(err);
41714
+ } finally {
41715
+ _iterator.f();
41716
+ }
41717
+ }
41718
+
41719
+ // ── Floating labels above each switch (on the tilted top) ────────────────
41720
+ lo = spec.labelOffset || {
41721
+ x: 0,
41722
+ y: 0,
41723
+ z: 0.7
41724
+ };
41725
+ labelSize = (_spec$labelSize = spec.labelSize) !== null && _spec$labelSize !== void 0 ? _spec$labelSize : 0.5;
41726
+ switches.forEach(function (sw, i) {
41727
+ var _lo$z;
41728
+ var label = _this2._makeLabel(sw.label || sw.attachmentId, labelSize);
41729
+ if (!label) return;
41730
+ label.position.set(xs[i] + (lo.x || 0), lo.y || 0, topZ + ((_lo$z = lo.z) !== null && _lo$z !== void 0 ? _lo$z : 0.7));
41731
+ top.add(label);
41732
+ _this2._labels.push(label);
41733
+ });
41734
+ case 3:
41735
+ return _context.a(2);
41736
+ }
41737
+ }, _callee, this);
41738
+ }));
41739
+ function _build() {
41740
+ return _build2.apply(this, arguments);
41741
+ }
41742
+ return _build;
41743
+ }()
41744
+ /**
41745
+ * Build one text label as a camera-facing `THREE.Sprite` (canvas texture),
41746
+ * drawn by the main WebGL renderer alongside the scene — no dependency on the
41747
+ * tooltip CSS2D renderer. Returns null when there is no DOM (headless tests).
41748
+ * @param {string} text
41749
+ * @param {number} worldHeight - label height in world units
41750
+ * @returns {THREE.Sprite|null}
41751
+ * @private
41752
+ */
41753
+ )
41754
+ }, {
41755
+ key: "_makeLabel",
41756
+ value: function _makeLabel(text, worldHeight) {
41757
+ if (typeof document === 'undefined') return null;
41758
+ var dpr = 4; // supersample for crisp text
41759
+ var fontPx = 40;
41760
+ var padX = 14;
41761
+ var padY = 8;
41762
+ var font = "600 ".concat(fontPx, "px -apple-system, \"Segoe UI\", Roboto, sans-serif");
41763
+ var canvas = document.createElement('canvas');
41764
+ var ctx = canvas.getContext('2d');
41765
+ ctx.font = font;
41766
+ var textW = ctx.measureText(text).width;
41767
+ canvas.width = Math.ceil((textW + padX * 2) * dpr);
41768
+ canvas.height = Math.ceil((fontPx + padY * 2) * dpr);
41769
+
41770
+ // Re-apply after the resize (which clears the canvas), scaled by dpr.
41771
+ ctx.scale(dpr, dpr);
41772
+ ctx.font = font;
41773
+ var wCss = canvas.width / dpr;
41774
+ var hCss = canvas.height / dpr;
41775
+ ctx.fillStyle = 'rgba(20,28,38,0.86)';
41776
+ this._roundRect(ctx, 0, 0, wCss, hCss, 8);
41777
+ ctx.fill();
41778
+ ctx.fillStyle = '#eaf2f8';
41779
+ ctx.textAlign = 'center';
41780
+ ctx.textBaseline = 'middle';
41781
+ ctx.fillText(text, wCss / 2, hCss / 2);
41782
+ var texture = new THREE__namespace.CanvasTexture(canvas);
41783
+ texture.minFilter = THREE__namespace.LinearFilter;
41784
+ texture.anisotropy = 4;
41785
+ var material = new THREE__namespace.SpriteMaterial({
41786
+ map: texture,
41787
+ transparent: true,
41788
+ depthTest: false,
41789
+ // always readable, never occluded by the board/switch
41790
+ depthWrite: false
41791
+ });
41792
+ var sprite = new THREE__namespace.Sprite(material);
41793
+ var aspect = canvas.width / canvas.height;
41794
+ sprite.scale.set(worldHeight * aspect, worldHeight, 1);
41795
+ sprite.renderOrder = 999;
41796
+ return sprite;
41797
+ }
41798
+
41799
+ /** Trace a rounded-rect path (fill applied by caller). @private */
41800
+ }, {
41801
+ key: "_roundRect",
41802
+ value: function _roundRect(ctx, x, y, w, h, r) {
41803
+ var rr = Math.min(r, w / 2, h / 2);
41804
+ ctx.beginPath();
41805
+ ctx.moveTo(x + rr, y);
41806
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
41807
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
41808
+ ctx.arcTo(x, y + h, x, y, rr);
41809
+ ctx.arcTo(x, y, x + w, y, rr);
41810
+ ctx.closePath();
41811
+ }
41812
+
41813
+ /**
41814
+ * Remove the panel, its labels, and its registered behaviors (scene reload).
41815
+ * @private
41816
+ */
41817
+ }, {
41818
+ key: "_teardown",
41819
+ value: function _teardown() {
41820
+ this._labels = [];
41821
+ if (this._panelId) {
41822
+ var _this$sceneViewer4;
41823
+ (_this$sceneViewer4 = this.sceneViewer) === null || _this$sceneViewer4 === void 0 || (_this$sceneViewer4 = _this$sceneViewer4.managers) === null || _this$sceneViewer4 === void 0 || (_this$sceneViewer4 = _this$sceneViewer4.ioBehaviorManager) === null || _this$sceneViewer4 === void 0 || _this$sceneViewer4.unloadForComponent(this._panelId);
41824
+ }
41825
+ if (this._panel) {
41826
+ var _this$sceneViewer5;
41827
+ (_this$sceneViewer5 = this.sceneViewer) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.scene) === null || _this$sceneViewer5 === void 0 || _this$sceneViewer5.remove(this._panel);
41828
+ this._panel.traverse(function (obj) {
41829
+ if (obj.isMesh || obj.isSprite) {
41830
+ var _obj$geometry, _obj$geometry$dispose;
41831
+ (_obj$geometry = obj.geometry) === null || _obj$geometry === void 0 || (_obj$geometry$dispose = _obj$geometry.dispose) === null || _obj$geometry$dispose === void 0 || _obj$geometry$dispose.call(_obj$geometry);
41832
+ var mats = Array.isArray(obj.material) ? obj.material : [obj.material];
41833
+ mats.forEach(function (m) {
41834
+ var _m$map, _m$map$dispose, _m$dispose;
41835
+ m === null || m === void 0 || (_m$map = m.map) === null || _m$map === void 0 || (_m$map$dispose = _m$map.dispose) === null || _m$map$dispose === void 0 || _m$map$dispose.call(_m$map); // sprite canvas texture
41836
+ m === null || m === void 0 || (_m$dispose = m.dispose) === null || _m$dispose === void 0 || _m$dispose.call(m);
41837
+ });
41838
+ }
41839
+ });
41840
+ this._panel = null;
41841
+ }
41842
+ this._panelId = null;
41843
+ }
41844
+ }, {
41845
+ key: "_doDispose",
41846
+ value: function _doDispose() {
41847
+ var _this$sceneViewer6;
41848
+ if ((_this$sceneViewer6 = this.sceneViewer) !== null && _this$sceneViewer6 !== void 0 && _this$sceneViewer6.off) {
41849
+ this.sceneViewer.off('scene-loaded', this._build);
41850
+ }
41851
+ this._teardown();
41852
+ this.sceneViewer = null;
41853
+ }
41854
+ }]);
41855
+ }(BaseDisposable);
41856
+
40272
41857
  // ─────────────────────────────────────────────────────────────────────────────
40273
41858
  // Flow-direction helpers (module-level)
40274
41859
  // ─────────────────────────────────────────────────────────────────────────────
@@ -40392,6 +41977,8 @@ var CentralPlantInternals = /*#__PURE__*/function () {
40392
41977
  this.centralPlant.managers.viewport2DManager = new Viewport2DManager(this.centralPlant.sceneViewer);
40393
41978
  this.centralPlant.managers.ioBehaviorManager = new IoBehaviorManager(this.centralPlant.sceneViewer);
40394
41979
  this.centralPlant.managers.ioOutlineManager = new IoOutlineManager(this.centralPlant.sceneViewer);
41980
+ this.centralPlant.managers.effectiveVisualManager = new EffectiveVisualManager(this.centralPlant.sceneViewer);
41981
+ this.centralPlant.managers.controlPanelManager = new ControlPanelManager(this.centralPlant.sceneViewer);
40395
41982
 
40396
41983
  // All managers are now stored in the managers collection and will be attached via attachToComponent()
40397
41984
  }
@@ -41825,7 +43412,7 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
41825
43412
  * Initialize the CentralPlant manager
41826
43413
  *
41827
43414
  * @constructor
41828
- * @version 0.3.57
43415
+ * @version 0.3.59
41829
43416
  * @updated 2025-10-22
41830
43417
  *
41831
43418
  * @description Creates a new CentralPlant instance and initializes internal managers and utilities.
@@ -41864,6 +43451,14 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
41864
43451
  // Initialize internals handler
41865
43452
  _this.internals = new CentralPlantInternals(_this);
41866
43453
 
43454
+ // Portable State Engine: single source of truth for desired/effective state
43455
+ // (plant/group/object/pipe). Owned here; the write door and scene load/save
43456
+ // route through it. The 3D layer/store mirror its output (Framework §12–§13).
43457
+ _this.stateEngine = new StateEngine();
43458
+ _this._unsubscribeStateEngine = _this.stateEngine.subscribe(function (change) {
43459
+ return _this._onEngineStateChange(change);
43460
+ });
43461
+
41867
43462
  // Optional cache primer function (dependency injection)
41868
43463
  _this.cachePrimer = null;
41869
43464
 
@@ -42040,6 +43635,16 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
42040
43635
  timestamp: new Date().toISOString()
42041
43636
  });
42042
43637
 
43638
+ // Load plant / groups / chains / stateRegistry into the State Engine. v3.0
43639
+ // scenes carry these; legacy v2.3 scenes load with safe defaults (§15).
43640
+ if (this.stateEngine) {
43641
+ try {
43642
+ this.stateEngine.load(extractDeclaration(sceneData));
43643
+ } catch (err) {
43644
+ console.warn('⚠️ setImportedSceneData(): stateEngine.load() failed:', err);
43645
+ }
43646
+ }
43647
+
42043
43648
  // Scene behaviors loaded
42044
43649
 
42045
43650
  // Cross-component behaviors are registered by sceneOperationsManager.loadSceneData()
@@ -42988,12 +44593,32 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
42988
44593
  }
42989
44594
 
42990
44595
  /**
42991
- * Internal single path for I/O state changes: persist, animate, link, emit.
44596
+ * Internal single path for I/O state changes. Routes the write through the
44597
+ * State Engine (the one write door, Framework §12); the engine subscription
44598
+ * ({@link CentralPlant#_onEngineStateChange}) mirrors to the state adapter,
44599
+ * animates meshes, and emits. Falls back to a direct write only if the engine
44600
+ * is unavailable.
42992
44601
  * @private
42993
44602
  */
42994
44603
  }, {
42995
44604
  key: "_dispatchIoState",
42996
44605
  value: function _dispatchIoState(attachmentId, stateId, value, parentUuid) {
44606
+ if (this.stateEngine) {
44607
+ this.stateEngine.setDesired(ioAddress(attachmentId, stateId, parentUuid), value);
44608
+ return;
44609
+ }
44610
+ this._dispatchIoStateDirect(attachmentId, stateId, value, parentUuid);
44611
+ }
44612
+
44613
+ /**
44614
+ * Persist to the state adapter, animate meshes, and emit for one I/O data
44615
+ * point. This is the mirror side of the engine (driven from desired, since I/O
44616
+ * data points are ungated) and the legacy fallback when no engine is present.
44617
+ * @private
44618
+ */
44619
+ }, {
44620
+ key: "_dispatchIoStateDirect",
44621
+ value: function _dispatchIoStateDirect(attachmentId, stateId, value, parentUuid) {
42997
44622
  var _this$managers, _this$sceneViewer5, _this$sceneViewer6, _this$managers2, _this$sceneViewer7, _this$sceneViewer8, _this$sceneViewer9;
42998
44623
  var tooltipMgr = ((_this$managers = this.managers) === null || _this$managers === void 0 ? void 0 : _this$managers.componentTooltipManager) || ((_this$sceneViewer5 = this.sceneViewer) === null || _this$sceneViewer5 === void 0 ? void 0 : _this$sceneViewer5.componentTooltipManager) || ((_this$sceneViewer6 = this.sceneViewer) === null || _this$sceneViewer6 === void 0 || (_this$sceneViewer6 = _this$sceneViewer6.managers) === null || _this$sceneViewer6 === void 0 ? void 0 : _this$sceneViewer6.componentTooltipManager);
42999
44624
  var stateAdapter = tooltipMgr === null || tooltipMgr === void 0 ? void 0 : tooltipMgr._stateAdapter;
@@ -43017,6 +44642,25 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
43017
44642
  });
43018
44643
  }
43019
44644
 
44645
+ /**
44646
+ * State Engine subscriber: fan a committed registry change out to the 3D/store
44647
+ * layer. I/O device data points (3-part addresses) mirror to the adapter and
44648
+ * animate; every change (incl. plant/group/object/pipe effective) is surfaced
44649
+ * as `engine-state-changed` for gating-aware visual subscribers (§13).
44650
+ * @private
44651
+ * @param {{ address: string, desired: *, effective: * }} change
44652
+ */
44653
+ }, {
44654
+ key: "_onEngineStateChange",
44655
+ value: function _onEngineStateChange(change) {
44656
+ var _this$sceneViewer0;
44657
+ var io = parseIoAddress(change.address);
44658
+ if (io) {
44659
+ this._dispatchIoStateDirect(io.attachmentId, io.stateId, change.desired, io.parentUuid);
44660
+ }
44661
+ (_this$sceneViewer0 = this.sceneViewer) === null || _this$sceneViewer0 === void 0 || _this$sceneViewer0.emit('engine-state-changed', change);
44662
+ }
44663
+
43020
44664
  /**
43021
44665
  * Configure the state adapter on both tooltip and behavior managers.
43022
44666
  * @param {{ getState: Function, setState: Function }} stateAdapter
@@ -43024,16 +44668,16 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
43024
44668
  }, {
43025
44669
  key: "configureStateAdapter",
43026
44670
  value: function configureStateAdapter(stateAdapter) {
43027
- var _this$managers3, _this$sceneViewer0, _this$managers4, _this$sceneViewer10, _this$sceneViewer11;
43028
- var tooltipMgr = ((_this$managers3 = this.managers) === null || _this$managers3 === void 0 ? void 0 : _this$managers3.componentTooltipManager) || ((_this$sceneViewer0 = this.sceneViewer) === null || _this$sceneViewer0 === void 0 ? void 0 : _this$sceneViewer0.componentTooltipManager);
44671
+ var _this$managers3, _this$sceneViewer1, _this$managers4, _this$sceneViewer11, _this$sceneViewer12;
44672
+ var tooltipMgr = ((_this$managers3 = this.managers) === null || _this$managers3 === void 0 ? void 0 : _this$managers3.componentTooltipManager) || ((_this$sceneViewer1 = this.sceneViewer) === null || _this$sceneViewer1 === void 0 ? void 0 : _this$sceneViewer1.componentTooltipManager);
43029
44673
  if (tooltipMgr !== null && tooltipMgr !== void 0 && tooltipMgr.configure) {
43030
- var _this$sceneViewer1;
44674
+ var _this$sceneViewer10;
43031
44675
  tooltipMgr.configure(stateAdapter);
43032
- if ((_this$sceneViewer1 = this.sceneViewer) !== null && _this$sceneViewer1 !== void 0 && _this$sceneViewer1.managers) {
44676
+ if ((_this$sceneViewer10 = this.sceneViewer) !== null && _this$sceneViewer10 !== void 0 && _this$sceneViewer10.managers) {
43033
44677
  this.sceneViewer.managers.componentTooltipManager = tooltipMgr;
43034
44678
  }
43035
44679
  }
43036
- var ioBehavMgr = ((_this$managers4 = this.managers) === null || _this$managers4 === void 0 ? void 0 : _this$managers4.ioBehaviorManager) || ((_this$sceneViewer10 = this.sceneViewer) === null || _this$sceneViewer10 === void 0 || (_this$sceneViewer10 = _this$sceneViewer10.managers) === null || _this$sceneViewer10 === void 0 ? void 0 : _this$sceneViewer10.ioBehaviorManager) || ((_this$sceneViewer11 = this.sceneViewer) === null || _this$sceneViewer11 === void 0 ? void 0 : _this$sceneViewer11.ioBehaviorManager);
44680
+ var ioBehavMgr = ((_this$managers4 = this.managers) === null || _this$managers4 === void 0 ? void 0 : _this$managers4.ioBehaviorManager) || ((_this$sceneViewer11 = this.sceneViewer) === null || _this$sceneViewer11 === void 0 || (_this$sceneViewer11 = _this$sceneViewer11.managers) === null || _this$sceneViewer11 === void 0 ? void 0 : _this$sceneViewer11.ioBehaviorManager) || ((_this$sceneViewer12 = this.sceneViewer) === null || _this$sceneViewer12 === void 0 ? void 0 : _this$sceneViewer12.ioBehaviorManager);
43037
44681
  if (ioBehavMgr !== null && ioBehavMgr !== void 0 && ioBehavMgr.configure) {
43038
44682
  ioBehavMgr.configure(stateAdapter);
43039
44683
  }
@@ -43068,8 +44712,8 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
43068
44712
  }, {
43069
44713
  key: "getSceneAttachments",
43070
44714
  value: function getSceneAttachments() {
43071
- var _this$sceneViewer12;
43072
- var scene = (_this$sceneViewer12 = this.sceneViewer) === null || _this$sceneViewer12 === void 0 ? void 0 : _this$sceneViewer12.scene;
44715
+ var _this$sceneViewer13;
44716
+ var scene = (_this$sceneViewer13 = this.sceneViewer) === null || _this$sceneViewer13 === void 0 ? void 0 : _this$sceneViewer13.scene;
43073
44717
  if (!scene) return [];
43074
44718
  var results = [];
43075
44719
  scene.traverse(function (obj) {
@@ -44850,12 +46494,14 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
44850
46494
  }, {
44851
46495
  key: "clearScene",
44852
46496
  value: function clearScene() {
44853
- var _this$sceneViewer13;
46497
+ var _this$sceneViewer14, _this$stateEngine;
44854
46498
  this.importedSceneData = null;
44855
- var ioBehavMgr = (_this$sceneViewer13 = this.sceneViewer) === null || _this$sceneViewer13 === void 0 || (_this$sceneViewer13 = _this$sceneViewer13.managers) === null || _this$sceneViewer13 === void 0 ? void 0 : _this$sceneViewer13.ioBehaviorManager;
46499
+ var ioBehavMgr = (_this$sceneViewer14 = this.sceneViewer) === null || _this$sceneViewer14 === void 0 || (_this$sceneViewer14 = _this$sceneViewer14.managers) === null || _this$sceneViewer14 === void 0 ? void 0 : _this$sceneViewer14.ioBehaviorManager;
44856
46500
  if (ioBehavMgr) {
44857
46501
  ioBehavMgr.setCrossComponentBehaviors([]);
44858
46502
  }
46503
+ // Reset the registry so pipe/object keys from the old scene don't linger (§15).
46504
+ (_this$stateEngine = this.stateEngine) === null || _this$stateEngine === void 0 || _this$stateEngine.reset();
44859
46505
  if (this.sceneViewer && this.sceneViewer.scene) {
44860
46506
  this.sceneViewer.scene.clear();
44861
46507
  }
@@ -44906,8 +46552,16 @@ var CentralPlant = /*#__PURE__*/function (_BaseDisposable) {
44906
46552
  }, {
44907
46553
  key: "_doDispose",
44908
46554
  value: function _doDispose() {
46555
+ var _this$stateEngine2;
44909
46556
  console.log('🧹 Disposing CentralPlant using enhanced disposal patterns...');
44910
46557
 
46558
+ // Tear down the State Engine subscription and registry.
46559
+ if (this._unsubscribeStateEngine) {
46560
+ this._unsubscribeStateEngine();
46561
+ this._unsubscribeStateEngine = null;
46562
+ }
46563
+ (_this$stateEngine2 = this.stateEngine) === null || _this$stateEngine2 === void 0 || _this$stateEngine2.reset();
46564
+
44911
46565
  // Use batch disposal for all managers
44912
46566
  var managers = Object.values(this.managers).filter(function (manager) {
44913
46567
  return manager && typeof manager.dispose === 'function';
@@ -50781,8 +52435,10 @@ exports.SceneInitializationManager = SceneInitializationManager;
50781
52435
  exports.SceneOperationsManager = SceneOperationsManager;
50782
52436
  exports.SceneTooltipsManager = SceneTooltipsManager;
50783
52437
  exports.SnapshotManager = SnapshotManager;
52438
+ exports.StateEngine = StateEngine;
50784
52439
  exports.ThreeJSResourceManager = ThreeJSResourceManager;
50785
52440
  exports.applyCrossComponentBehaviors = applyCrossComponentBehaviors;
52441
+ exports.applyDeclarationToExport = applyDeclarationToExport;
50786
52442
  exports.applyDefaultIoDeviceStates = applyDefaultIoDeviceStates;
50787
52443
  exports.applyModelRootTranslation = applyModelRootTranslation;
50788
52444
  exports.applyModelRootTranslationFromWorldBase = applyModelRootTranslationFromWorldBase;
@@ -50796,6 +52452,7 @@ exports.clearS3Cache = clearS3Cache;
50796
52452
  exports.collectSceneLibraryIds = collectSceneLibraryIds;
50797
52453
  exports.createPathfindingRequest = createPathfindingRequest;
50798
52454
  exports.createTransformControls = createTransformControls;
52455
+ exports.extractDeclaration = extractDeclaration;
50799
52456
  exports.findObjectByHardcodedUuid = findObjectByHardcodedUuid;
50800
52457
  exports.formatCacheExpiry = formatCacheExpiry;
50801
52458
  exports.generateUniqueComponentId = generateUniqueComponentId;
@@ -50821,6 +52478,7 @@ exports.getObjectsByType = getObjectsByType;
50821
52478
  exports.getScopedAttachmentKey = getScopedAttachmentKey;
50822
52479
  exports.getThumbnailKey = getThumbnailKey;
50823
52480
  exports.getUserOnlyCacheStats = getUserOnlyCacheStats;
52481
+ exports.ioAddress = ioAddress;
50824
52482
  exports.isCached = isCached;
50825
52483
  exports.isComponent = isComponent;
50826
52484
  exports.isComputed = isComputed;
@@ -50832,14 +52490,17 @@ exports.isSegment = isSegment;
50832
52490
  exports.isThumbnailCached = isThumbnailCached;
50833
52491
  exports.loadCrossComponentBehaviors = loadCrossComponentBehaviors;
50834
52492
  exports.loadTextureSetAndCreateMaterial = loadTextureSetAndCreateMaterial;
52493
+ exports.makeAddress = makeAddress;
50835
52494
  exports.markAsComputed = markAsComputed;
50836
52495
  exports.markAsDeclared = markAsDeclared;
50837
52496
  exports.measureS3Transfer = measureS3Transfer;
50838
52497
  exports.modelOffsetToWorldDelta = modelOffsetToWorldDelta;
50839
52498
  exports.modelPreloader = modelPreloader;
50840
52499
  exports.normalizeBehavior = normalizeBehavior;
52500
+ exports.offValue = offValue;
50841
52501
  exports.parseCrossBehavior = parseCrossBehavior;
50842
52502
  exports.parseIntraBehavior = parseIntraBehavior;
52503
+ exports.parseIoAddress = parseIoAddress;
50843
52504
  exports.preloadLocalFiles = preloadLocalFiles;
50844
52505
  exports.preloadS3Objects = preloadS3Objects;
50845
52506
  exports.refreshSceneIntraBehaviors = refreshSceneIntraBehaviors;
@@ -50856,3 +52517,4 @@ exports.scanSceneIoEndpoints = scanSceneIoEndpoints;
50856
52517
  exports.sceneViewer = sceneViewer;
50857
52518
  exports.shouldRemoveOnRegeneration = shouldRemoveOnRegeneration;
50858
52519
  exports.switchCachePartition = switchCachePartition;
52520
+ exports.valuesEqual = valuesEqual;