@2112-lab/central-plant 0.3.58 → 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 (27) hide show
  1. package/dist/bundle/index.js +1635 -32
  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/pathfinding/PathFlowManager.js +94 -13
  9. package/dist/cjs/src/managers/pathfinding/PathRenderingManager.js +15 -0
  10. package/dist/cjs/src/managers/scene/componentTooltipManager.js +7 -1
  11. package/dist/cjs/src/managers/scene/controlPanelManager.js +377 -0
  12. package/dist/cjs/src/managers/scene/sceneExportManager.js +10 -1
  13. package/dist/cjs/src/managers/state/EffectiveVisualManager.js +270 -0
  14. package/dist/esm/src/core/centralPlant.js +80 -11
  15. package/dist/esm/src/core/centralPlantInternals.js +4 -0
  16. package/dist/esm/src/core/centralPlantValidator.js +10 -6
  17. package/dist/esm/src/core/stateEngine.js +725 -0
  18. package/dist/esm/src/core/stateEngineScene.js +90 -0
  19. package/dist/esm/src/index.js +2 -0
  20. package/dist/esm/src/managers/pathfinding/PathFlowManager.js +94 -13
  21. package/dist/esm/src/managers/pathfinding/PathRenderingManager.js +15 -0
  22. package/dist/esm/src/managers/scene/componentTooltipManager.js +7 -1
  23. package/dist/esm/src/managers/scene/controlPanelManager.js +352 -0
  24. package/dist/esm/src/managers/scene/sceneExportManager.js +10 -1
  25. package/dist/esm/src/managers/state/EffectiveVisualManager.js +266 -0
  26. package/dist/index.d.ts +85 -0
  27. package/package.json +5 -2
@@ -0,0 +1,90 @@
1
+ import { slicedToArray as _slicedToArray } from '../../_virtual/_rollupPluginBabelHelpers.js';
2
+ import { makeAddress } from './stateEngine.js';
3
+ import { getScopedAttachmentKey } from '../utils/behaviorDispatch.js';
4
+
5
+ /**
6
+ * Registry address for an I/O device data point.
7
+ * Shape: `${parentUuid}::${attachmentId}::${stateId}` (matches §17
8
+ * `pump-001::start-switch::mode`). The scoped key is `${parentUuid}::${attachmentId}`.
9
+ *
10
+ * @param {string} attachmentId
11
+ * @param {string} stateId
12
+ * @param {string} [parentUuid]
13
+ * @returns {string}
14
+ */
15
+ function ioAddress(attachmentId, stateId, parentUuid) {
16
+ return makeAddress(getScopedAttachmentKey(attachmentId, parentUuid), stateId);
17
+ }
18
+
19
+ /**
20
+ * Parse an I/O device address back into its parts. Returns null for
21
+ * plant/group/object/pipe addresses (2-part) — I/O device data points are 3-part
22
+ * because a device always has a parent component (§8; free-floating I/O rejected).
23
+ *
24
+ * @param {string} address
25
+ * @returns {{ parentUuid: string, attachmentId: string, stateId: string, scopedKey: string } | null}
26
+ */
27
+ function parseIoAddress(address) {
28
+ if (typeof address !== 'string') return null;
29
+ var parts = address.split('::');
30
+ if (parts.length !== 3) return null;
31
+ var _parts = _slicedToArray(parts, 3),
32
+ parentUuid = _parts[0],
33
+ attachmentId = _parts[1],
34
+ stateId = _parts[2];
35
+ return {
36
+ parentUuid: parentUuid,
37
+ attachmentId: attachmentId,
38
+ stateId: stateId,
39
+ scopedKey: "".concat(parentUuid, "::").concat(attachmentId)
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Read a State Engine declaration out of scene data, tolerating legacy scenes.
45
+ * v3.0 scenes carry `plant` / `groups` / `chains` / `stateRegistry`; older v2.3
46
+ * scenes have none of these and load with safe defaults (plant power on, no
47
+ * groups, no chains) so nothing crashes on unknown/missing fields (§15).
48
+ *
49
+ * @param {object} [sceneData]
50
+ * @returns {{ plant: object, groups: object, chains: object[], stateRegistry: object }}
51
+ */
52
+ function extractDeclaration() {
53
+ var sceneData = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
54
+ return {
55
+ plant: sceneData.plant || {
56
+ states: {
57
+ power: {
58
+ type: 'Boolean',
59
+ default: true,
60
+ gate: true
61
+ }
62
+ }
63
+ },
64
+ groups: sceneData.groups || {},
65
+ chains: Array.isArray(sceneData.chains) ? sceneData.chains : [],
66
+ stateRegistry: sceneData.stateRegistry || {}
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Merge a State Engine's current declaration (plant / groups / chains /
72
+ * stateRegistry) into an export object, bumping it to version 3.0. Mutates and
73
+ * returns `exportData`.
74
+ *
75
+ * @param {object} exportData - the scene export object being assembled
76
+ * @param {{ toDeclaration: () => object }} engine
77
+ * @returns {object} exportData
78
+ */
79
+ function applyDeclarationToExport(exportData, engine) {
80
+ if (!exportData || !engine || typeof engine.toDeclaration !== 'function') return exportData;
81
+ var declaration = engine.toDeclaration();
82
+ exportData.version = '3.0';
83
+ exportData.plant = declaration.plant;
84
+ exportData.groups = declaration.groups;
85
+ if (declaration.chains.length > 0) exportData.chains = declaration.chains;
86
+ exportData.stateRegistry = declaration.stateRegistry;
87
+ return exportData;
88
+ }
89
+
90
+ export { applyDeclarationToExport, extractDeclaration, ioAddress, parseIoAddress };
@@ -1,5 +1,7 @@
1
1
  export { default as CentralPlant } from './core/centralPlant.js';
2
2
  export { default as sceneViewer } from './core/sceneViewer.js';
3
+ export { StateEngine, makeAddress, offValue, valuesEqual } from './core/stateEngine.js';
4
+ export { applyDeclarationToExport, extractDeclaration, ioAddress, parseIoAddress } from './core/stateEngineScene.js';
3
5
  export { findObjectByHardcodedUuid, generateUniqueComponentId, generateUuidFromName, getHardcodedUuid } from './utils/nameUtils.js';
4
6
  export { FLOW_ATTRIBUTE_KEYS, PathData, createPathfindingRequest } from './core/pathfindingData.js';
5
7
  export { getObjectTypeName, getObjectsByType, isComponent, isComputed, isConnector, isDeclared, isExportable, isGateway, isSegment, markAsComputed, markAsDeclared, shouldRemoveOnRegeneration } from './utils/objectTypes.js';
@@ -10,6 +10,9 @@ var TEMP_MIN = 0;
10
10
  /** Maximum temperature in the colour ramp (maps to pure red). */
11
11
  var TEMP_MAX = 100;
12
12
 
13
+ /** Colour a pipe is painted when its effective flow is gated off (§6.1). */
14
+ var PIPE_OFF_GRAY = '#3a3a3a';
15
+
13
16
  /**
14
17
  * Convert a temperature value to a CSS hex colour string using a blue→red HSL
15
18
  * ramp. Values are clamped to [TEMP_MIN, TEMP_MAX].
@@ -47,22 +50,78 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
47
50
  * @type {Map<string, Record<string, any>>}
48
51
  */
49
52
  _this._derivedStore = new Map();
53
+
54
+ /**
55
+ * Paths whose effective flow is gated off (State Engine §6.1). While a path
56
+ * is in this set it is painted {@link PIPE_OFF_GRAY} and temperature updates
57
+ * are suppressed so they don't override the off-look.
58
+ * @type {Set<string>}
59
+ */
60
+ _this._flowGatedOff = new Set();
61
+
62
+ /**
63
+ * Baseline (on-state) colour snapshotted the moment a path is gated off, so
64
+ * it can be restored exactly when flow returns — including pipes with no
65
+ * declared flowTemperature, whose colour is the base pipe material.
66
+ * @type {Map<string, string>}
67
+ */
68
+ _this._flowBaseColor = new Map();
50
69
  return _this;
51
70
  }
52
71
 
53
- // ── Public API attribute write ──────────────────────────────────────────
72
+ // ── Flow gating (State Engine effective → 3D bridge, §6.1) ─────────────────
54
73
 
55
74
  /**
56
- * Set a declared (persistent) flow attribute on a path.
57
- * The value is stored in PathData and will be serialised with the scene.
58
- * Triggers a visual update.
75
+ * Gate a path's flow visual on/off from the State Engine's effective `flow`.
76
+ * Off paint the shared per-path material gray; on restore the temperature
77
+ * colour. The off state is sticky (see {@link _flowGatedOff}).
59
78
  *
60
- * @param {string} pathId - e.g. "PUMP-1-CONN-1-->CHILLER-1-CONN-2"
61
- * @param {string} key - One of FLOW_ATTRIBUTE_KEYS
62
- * @param {any} value
79
+ * @param {string} pathId - `${from}-->${to}`
80
+ * @param {boolean} enabled - Effective flow
63
81
  */
64
82
  _inherits(PathFlowManager, _BaseDisposable);
65
83
  return _createClass(PathFlowManager, [{
84
+ key: "setFlowEnabled",
85
+ value: function setFlowEnabled(pathId, enabled) {
86
+ var split = this._splitPathId(pathId);
87
+ if (!split) return;
88
+ var renderingManager = this._getRenderingManager();
89
+ if (enabled) {
90
+ this._flowGatedOff.delete(pathId);
91
+ // Prefer a temperature-derived colour if one is declared; otherwise
92
+ // restore the baseline snapshotted when the pipe was gated off (this is
93
+ // what fixes plain, non-temperature pipes getting stuck gray).
94
+ var temp = this.resolve(pathId, 'flowTemperature');
95
+ if (temp !== null && temp !== undefined) {
96
+ this.applyVisualizationForPath(pathId);
97
+ } else if (this._flowBaseColor.has(pathId)) {
98
+ renderingManager === null || renderingManager === void 0 || renderingManager.updatePathColor(split.from, split.to, this._flowBaseColor.get(pathId));
99
+ }
100
+ this._flowBaseColor.delete(pathId);
101
+ } else {
102
+ // Snapshot the current (on-state) colour once, before overpainting gray.
103
+ if (!this._flowGatedOff.has(pathId) && renderingManager) {
104
+ var _renderingManager$get;
105
+ var base = (_renderingManager$get = renderingManager.getPathColorHex) === null || _renderingManager$get === void 0 ? void 0 : _renderingManager$get.call(renderingManager, pathId);
106
+ if (base) this._flowBaseColor.set(pathId, base);
107
+ }
108
+ this._flowGatedOff.add(pathId);
109
+ renderingManager === null || renderingManager === void 0 || renderingManager.updatePathColor(split.from, split.to, PIPE_OFF_GRAY);
110
+ }
111
+ }
112
+
113
+ // ── Public API — attribute write ──────────────────────────────────────────
114
+
115
+ /**
116
+ * Set a declared (persistent) flow attribute on a path.
117
+ * The value is stored in PathData and will be serialised with the scene.
118
+ * Triggers a visual update.
119
+ *
120
+ * @param {string} pathId - e.g. "PUMP-1-CONN-1-->CHILLER-1-CONN-2"
121
+ * @param {string} key - One of FLOW_ATTRIBUTE_KEYS
122
+ * @param {any} value
123
+ */
124
+ }, {
66
125
  key: "setDeclared",
67
126
  value: function setDeclared(pathId, key, value) {
68
127
  var pd = this._getPathData(pathId);
@@ -168,6 +227,13 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
168
227
  }, {
169
228
  key: "applyVisualizationForPath",
170
229
  value: function applyVisualizationForPath(pathId) {
230
+ // Effective flow gated off (§6.1) — keep the off-look, ignore temperature.
231
+ if (this._flowGatedOff.has(pathId)) {
232
+ var _this$_getRenderingMa;
233
+ var _split = this._splitPathId(pathId);
234
+ if (_split) (_this$_getRenderingMa = this._getRenderingManager()) === null || _this$_getRenderingMa === void 0 || _this$_getRenderingMa.updatePathColor(_split.from, _split.to, PIPE_OFF_GRAY);
235
+ return;
236
+ }
171
237
  var temp = this.resolve(pathId, 'flowTemperature');
172
238
  if (temp === null || temp === undefined) {
173
239
  return; // No temperature declared — leave default pipe color
@@ -175,17 +241,30 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
175
241
  var color = temperatureToColor(temp);
176
242
  var renderingManager = this._getRenderingManager();
177
243
  if (!renderingManager) return;
244
+ var split = this._splitPathId(pathId);
245
+ if (!split) return;
246
+ renderingManager.updatePathColor(split.from, split.to, color);
247
+ console.log("\uD83C\uDF21\uFE0F PathFlowManager: \"".concat(pathId, "\" \u2192 flowTemperature ").concat(temp, " \u2192 ").concat(color));
248
+ }
178
249
 
179
- // pathId = "from-->to"; extract the two halves
250
+ /**
251
+ * Split a `${from}-->${to}` pathId into its two connector halves.
252
+ * @param {string} pathId
253
+ * @returns {{ from: string, to: string }|null} null if malformed
254
+ * @private
255
+ */
256
+ }, {
257
+ key: "_splitPathId",
258
+ value: function _splitPathId(pathId) {
180
259
  var sepIdx = pathId.indexOf('-->');
181
260
  if (sepIdx === -1) {
182
261
  console.warn("PathFlowManager: malformed pathId \"".concat(pathId, "\""));
183
- return;
262
+ return null;
184
263
  }
185
- var from = pathId.slice(0, sepIdx);
186
- var to = pathId.slice(sepIdx + 3);
187
- renderingManager.updatePathColor(from, to, color);
188
- console.log("\uD83C\uDF21\uFE0F PathFlowManager: \"".concat(pathId, "\" \u2192 flowTemperature ").concat(temp, " \u2192 ").concat(color));
264
+ return {
265
+ from: pathId.slice(0, sepIdx),
266
+ to: pathId.slice(sepIdx + 3)
267
+ };
189
268
  }
190
269
 
191
270
  /**
@@ -217,6 +296,8 @@ var PathFlowManager = /*#__PURE__*/function (_BaseDisposable) {
217
296
  key: "dispose",
218
297
  value: function dispose() {
219
298
  this._derivedStore.clear();
299
+ this._flowGatedOff.clear();
300
+ this._flowBaseColor.clear();
220
301
  this.sceneViewer = null;
221
302
  _superPropGet(PathFlowManager, "dispose", this, 3)([]);
222
303
  }
@@ -64,6 +64,21 @@ var PathRenderingManager = /*#__PURE__*/function (_BaseDisposable) {
64
64
  }
65
65
  }
66
66
 
67
+ /**
68
+ * Read the current color of a path's shared material as a `#rrggbb` string,
69
+ * or null if the path has no material. Used to snapshot a pipe's baseline
70
+ * colour before it is temporarily overpainted (e.g. flow gating).
71
+ *
72
+ * @param {string} pathId - "${from}-->${to}"
73
+ * @returns {string|null}
74
+ */
75
+ }, {
76
+ key: "getPathColorHex",
77
+ value: function getPathColorHex(pathId) {
78
+ var mat = this._pathMaterials.get(pathId);
79
+ return mat ? "#".concat(mat.color.getHexString()) : null;
80
+ }
81
+
67
82
  /**
68
83
  * Get path colors for visual distinction
69
84
  * @param {number} index - Path index
@@ -120,7 +120,6 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
120
120
  if (!ioDeviceObject || !this._stateAdapter) return;
121
121
  var ud = ioDeviceObject.userData;
122
122
  var attachmentId = ud === null || ud === void 0 ? void 0 : ud.attachmentId;
123
- var dataPoints = (ud === null || ud === void 0 ? void 0 : ud.dataPoints) || [];
124
123
  if (!attachmentId) return;
125
124
 
126
125
  // Walk up to find parent component UUID for scoped state/behavior handling
@@ -135,6 +134,13 @@ var ComponentTooltipManager = /*#__PURE__*/function (_BaseDisposable) {
135
134
  obj = obj.parent;
136
135
  }
137
136
 
137
+ // Derive the device's data points from its behaviorConfig (same resolver the
138
+ // drag path uses). `userData.dataPoints` is not populated for attached
139
+ // devices, so reading it directly would find nothing — resolve instead, and
140
+ // keep the legacy field only as a fallback.
141
+ var resolved = resolveDataPoints(parentUuid, attachmentId, ud, getIoBehaviorManager(this.sceneViewer), null);
142
+ var dataPoints = resolved !== null && resolved !== void 0 && resolved.length ? resolved : (ud === null || ud === void 0 ? void 0 : ud.dataPoints) || [];
143
+
138
144
  // Create a scoped attachment key to prevent state sharing between instances
139
145
  // of the same smart component that share the same attachmentId
140
146
  var scopedAttachmentId = getScopedAttachmentKey(attachmentId, parentUuid);
@@ -0,0 +1,352 @@
1
+ import { inherits as _inherits, createClass as _createClass, classCallCheck as _classCallCheck, callSuper as _callSuper, asyncToGenerator as _asyncToGenerator, regenerator as _regenerator, createForOfIteratorHelper as _createForOfIteratorHelper } from '../../../_virtual/_rollupPluginBabelHelpers.js';
2
+ import * as THREE from 'three';
3
+ import { BaseDisposable } from '../../core/baseDisposable.js';
4
+ import modelPreloader from '../../rendering/modelPreloader.js';
5
+ import { attachIODevicesToComponent } from '../../utils/ioDeviceUtils.js';
6
+ import { registerBehaviorsForComponent } from '../../utils/behaviorRegistration.js';
7
+
8
+ /**
9
+ * X offsets for `count` switches evenly spaced and centered on 0.
10
+ * @param {number} count
11
+ * @param {number} spacing
12
+ * @returns {number[]}
13
+ */
14
+ function computeSwitchRowX(count, spacing) {
15
+ var start = -((count - 1) * spacing) / 2;
16
+ return Array.from({
17
+ length: count
18
+ }, function (_, i) {
19
+ return start + i * spacing;
20
+ });
21
+ }
22
+ var ControlPanelManager = /*#__PURE__*/function (_BaseDisposable) {
23
+ /**
24
+ * @param {Object} sceneViewer - The central sceneViewer hub
25
+ */
26
+ function ControlPanelManager(sceneViewer) {
27
+ var _this;
28
+ _classCallCheck(this, ControlPanelManager);
29
+ _this = _callSuper(this, ControlPanelManager);
30
+ _this.sceneViewer = sceneViewer;
31
+ _this._panel = null;
32
+ _this._labels = [];
33
+ _this._panelId = null;
34
+ _this._build = _this._build.bind(_this);
35
+ if (sceneViewer !== null && sceneViewer !== void 0 && sceneViewer.on) {
36
+ sceneViewer.on('scene-loaded', _this._build);
37
+ }
38
+ return _this;
39
+ }
40
+
41
+ /**
42
+ * Build (or rebuild) the panel from the current scene's `controlPanel` spec.
43
+ * @private
44
+ */
45
+ _inherits(ControlPanelManager, _BaseDisposable);
46
+ return _createClass(ControlPanelManager, [{
47
+ key: "_build",
48
+ value: (function () {
49
+ var _build2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
50
+ var _this$sceneViewer,
51
+ _centralPlant$importe,
52
+ _this$sceneViewer2,
53
+ _b$width,
54
+ _b$height,
55
+ _b$thickness,
56
+ _desk$tilt,
57
+ _desk$height,
58
+ _desk$legThickness,
59
+ _desk$legInset,
60
+ _spec$switchSpacing,
61
+ _this$sceneViewer3,
62
+ _spec$labelSize,
63
+ _this2 = this;
64
+ 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;
65
+ return _regenerator().w(function (_context) {
66
+ while (1) switch (_context.n) {
67
+ case 0:
68
+ this._teardown();
69
+ centralPlant = (_this$sceneViewer = this.sceneViewer) === null || _this$sceneViewer === void 0 ? void 0 : _this$sceneViewer.centralPlant;
70
+ spec = centralPlant === null || centralPlant === void 0 || (_centralPlant$importe = centralPlant.importedSceneData) === null || _centralPlant$importe === void 0 ? void 0 : _centralPlant$importe.controlPanel;
71
+ scene = (_this$sceneViewer2 = this.sceneViewer) === null || _this$sceneViewer2 === void 0 ? void 0 : _this$sceneViewer2.scene;
72
+ if (!(!spec || !scene)) {
73
+ _context.n = 1;
74
+ break;
75
+ }
76
+ return _context.a(2);
77
+ case 1:
78
+ panelId = spec.id || 'CONTROL-PANEL-1';
79
+ this._panelId = panelId;
80
+ panel = new THREE.Group();
81
+ panel.name = 'Control Panel';
82
+ panel.uuid = panelId;
83
+ panel.userData = {
84
+ objectType: 'component',
85
+ isControlPanel: true
86
+ };
87
+ p = spec.position || {};
88
+ panel.position.set(p.x || 0, p.y || 0, p.z || 0);
89
+ r = spec.rotation || {};
90
+ d2r = Math.PI / 180;
91
+ panel.rotation.set((r.x || 0) * d2r, (r.y || 0) * d2r, (r.z || 0) * d2r, 'XYZ');
92
+ scale = Number.isFinite(spec.scale) && spec.scale > 0 ? spec.scale : 1;
93
+ panel.scale.setScalar(scale);
94
+
95
+ // ── Drawing-desk geometry (Z-up scene) ───────────────────────────────────
96
+ // A tilted top surface (the drafting board) raised on legs. Switches +
97
+ // labels live on the tilted `top` group so they sit on the angled surface.
98
+ b = spec.board || {};
99
+ width = (_b$width = b.width) !== null && _b$width !== void 0 ? _b$width : 7; // x-extent of the top
100
+ depth = (_b$height = b.height) !== null && _b$height !== void 0 ? _b$height : 2; // y-extent of the top (spec key kept as `height`)
101
+ thickness = (_b$thickness = b.thickness) !== null && _b$thickness !== void 0 ? _b$thickness : 0.15;
102
+ desk = spec.desk || {};
103
+ tilt = ((_desk$tilt = desk.tilt) !== null && _desk$tilt !== void 0 ? _desk$tilt : 30) * d2r; // surface tilt; +raises the back (+y) edge
104
+ deskHeight = (_desk$height = desk.height) !== null && _desk$height !== void 0 ? _desk$height : 1.3; // world height of the top's center
105
+ legThickness = (_desk$legThickness = desk.legThickness) !== null && _desk$legThickness !== void 0 ? _desk$legThickness : 0.18;
106
+ legInset = (_desk$legInset = desk.legInset) !== null && _desk$legInset !== void 0 ? _desk$legInset : 0.35;
107
+ legColor = desk.legColor || '#22303c'; // Tilted top group: raised to deskHeight, rotated about X.
108
+ top = new THREE.Group();
109
+ top.position.set(0, 0, deskHeight);
110
+ top.rotation.set(tilt, 0, 0, 'XYZ');
111
+ panel.add(top);
112
+ board = new THREE.Mesh(new THREE.BoxGeometry(width, depth, thickness), new THREE.MeshStandardMaterial({
113
+ color: b.color || '#2c3e50',
114
+ roughness: 0.6,
115
+ metalness: 0.2
116
+ }));
117
+ board.userData = {
118
+ objectType: 'control-panel-board'
119
+ };
120
+ top.add(board);
121
+
122
+ // Legs: one per corner, from the ground up to the tilted top's underside.
123
+ legMat = new THREE.MeshStandardMaterial({
124
+ color: legColor,
125
+ roughness: 0.7,
126
+ metalness: 0.1
127
+ });
128
+ halfW = Math.max(0.1, width / 2 - legInset);
129
+ halfD = Math.max(0.1, depth / 2 - legInset);
130
+ for (_i = 0, _arr = [halfD, -halfD]; _i < _arr.length; _i++) {
131
+ ly = _arr[_i];
132
+ // Underside height at this front/back row after the tilt.
133
+ cornerZ = deskHeight + ly * Math.sin(tilt) - thickness / 2 * Math.cos(tilt);
134
+ legH = Math.max(0.1, cornerZ);
135
+ yPos = ly * Math.cos(tilt); // corner's y in panel space after tilt
136
+ for (_i2 = 0, _arr2 = [halfW, -halfW]; _i2 < _arr2.length; _i2++) {
137
+ lx = _arr2[_i2];
138
+ leg = new THREE.Mesh(new THREE.BoxGeometry(legThickness, legThickness, legH), legMat);
139
+ leg.position.set(lx, yPos, legH / 2);
140
+ panel.add(leg);
141
+ }
142
+ }
143
+
144
+ // ── Switch layout: a row along X on the top face (+Z) of the board ───────
145
+ switches = Array.isArray(spec.switches) ? spec.switches : [];
146
+ spacing = (_spec$switchSpacing = spec.switchSpacing) !== null && _spec$switchSpacing !== void 0 ? _spec$switchSpacing : 1.3;
147
+ xs = computeSwitchRowX(switches.length, spacing);
148
+ topZ = thickness / 2;
149
+ attachedDevices = {};
150
+ switches.forEach(function (sw, i) {
151
+ attachedDevices[sw.attachmentId] = {
152
+ deviceId: spec.deviceId,
153
+ attachmentLabel: sw.label || sw.attachmentId,
154
+ attachmentPoint: {
155
+ position: {
156
+ x: xs[i],
157
+ y: 0,
158
+ z: topZ
159
+ },
160
+ rotation: spec.switchRotation || {
161
+ x: 0,
162
+ y: 0,
163
+ z: 0
164
+ }
165
+ }
166
+ };
167
+ });
168
+ componentData = {
169
+ isSmart: true,
170
+ attachedDevices: attachedDevices
171
+ }; // Clone + tag the switch devices onto the tilted top (reuses smart-component path).
172
+ _context.n = 2;
173
+ return attachIODevicesToComponent(top, componentData, modelPreloader, panelId);
174
+ case 2:
175
+ // Optional switch-only scale (attach path always leaves devices at 1:1).
176
+ switchScale = Number.isFinite(spec.switchScale) && spec.switchScale > 0 ? spec.switchScale : 1;
177
+ if (switchScale !== 1) {
178
+ top.traverse(function (obj) {
179
+ var _obj$userData;
180
+ if (((_obj$userData = obj.userData) === null || _obj$userData === void 0 ? void 0 : _obj$userData.objectType) === 'io-device') {
181
+ obj.scale.setScalar(switchScale);
182
+ }
183
+ });
184
+ }
185
+
186
+ // Panel must be in the scene before behaviors register (default-state seeding
187
+ // scans the live scene graph for io-devices).
188
+ scene.add(panel);
189
+ this._panel = panel;
190
+ 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;
191
+ if (ioBehavMgr) {
192
+ registerBehaviorsForComponent(ioBehavMgr, panelId, componentData, panel, modelPreloader.componentDictionary, centralPlant);
193
+
194
+ // Drive each switch's mesh to its current engine value so the initial
195
+ // pose/colour reflects the state. `registerBehaviorsForComponent`'s
196
+ // default-seeding writes through the engine, which no-ops when the value
197
+ // already matches the seeded `stateRegistry` — leaving the button at its
198
+ // neutral GLB rest pose. Trigger the animation directly to avoid that.
199
+ engine = centralPlant === null || centralPlant === void 0 ? void 0 : centralPlant.stateEngine;
200
+ _iterator = _createForOfIteratorHelper(switches);
201
+ try {
202
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
203
+ sw = _step.value;
204
+ current = engine === null || engine === void 0 ? void 0 : engine.getDesired("".concat(panelId, "::").concat(sw.attachmentId, "::mode"));
205
+ ioBehavMgr.triggerState(sw.attachmentId, 'mode', current !== null && current !== void 0 ? current : true, panelId);
206
+ }
207
+ } catch (err) {
208
+ _iterator.e(err);
209
+ } finally {
210
+ _iterator.f();
211
+ }
212
+ }
213
+
214
+ // ── Floating labels above each switch (on the tilted top) ────────────────
215
+ lo = spec.labelOffset || {
216
+ x: 0,
217
+ y: 0,
218
+ z: 0.7
219
+ };
220
+ labelSize = (_spec$labelSize = spec.labelSize) !== null && _spec$labelSize !== void 0 ? _spec$labelSize : 0.5;
221
+ switches.forEach(function (sw, i) {
222
+ var _lo$z;
223
+ var label = _this2._makeLabel(sw.label || sw.attachmentId, labelSize);
224
+ if (!label) return;
225
+ 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));
226
+ top.add(label);
227
+ _this2._labels.push(label);
228
+ });
229
+ case 3:
230
+ return _context.a(2);
231
+ }
232
+ }, _callee, this);
233
+ }));
234
+ function _build() {
235
+ return _build2.apply(this, arguments);
236
+ }
237
+ return _build;
238
+ }()
239
+ /**
240
+ * Build one text label as a camera-facing `THREE.Sprite` (canvas texture),
241
+ * drawn by the main WebGL renderer alongside the scene — no dependency on the
242
+ * tooltip CSS2D renderer. Returns null when there is no DOM (headless tests).
243
+ * @param {string} text
244
+ * @param {number} worldHeight - label height in world units
245
+ * @returns {THREE.Sprite|null}
246
+ * @private
247
+ */
248
+ )
249
+ }, {
250
+ key: "_makeLabel",
251
+ value: function _makeLabel(text, worldHeight) {
252
+ if (typeof document === 'undefined') return null;
253
+ var dpr = 4; // supersample for crisp text
254
+ var fontPx = 40;
255
+ var padX = 14;
256
+ var padY = 8;
257
+ var font = "600 ".concat(fontPx, "px -apple-system, \"Segoe UI\", Roboto, sans-serif");
258
+ var canvas = document.createElement('canvas');
259
+ var ctx = canvas.getContext('2d');
260
+ ctx.font = font;
261
+ var textW = ctx.measureText(text).width;
262
+ canvas.width = Math.ceil((textW + padX * 2) * dpr);
263
+ canvas.height = Math.ceil((fontPx + padY * 2) * dpr);
264
+
265
+ // Re-apply after the resize (which clears the canvas), scaled by dpr.
266
+ ctx.scale(dpr, dpr);
267
+ ctx.font = font;
268
+ var wCss = canvas.width / dpr;
269
+ var hCss = canvas.height / dpr;
270
+ ctx.fillStyle = 'rgba(20,28,38,0.86)';
271
+ this._roundRect(ctx, 0, 0, wCss, hCss, 8);
272
+ ctx.fill();
273
+ ctx.fillStyle = '#eaf2f8';
274
+ ctx.textAlign = 'center';
275
+ ctx.textBaseline = 'middle';
276
+ ctx.fillText(text, wCss / 2, hCss / 2);
277
+ var texture = new THREE.CanvasTexture(canvas);
278
+ texture.minFilter = THREE.LinearFilter;
279
+ texture.anisotropy = 4;
280
+ var material = new THREE.SpriteMaterial({
281
+ map: texture,
282
+ transparent: true,
283
+ depthTest: false,
284
+ // always readable, never occluded by the board/switch
285
+ depthWrite: false
286
+ });
287
+ var sprite = new THREE.Sprite(material);
288
+ var aspect = canvas.width / canvas.height;
289
+ sprite.scale.set(worldHeight * aspect, worldHeight, 1);
290
+ sprite.renderOrder = 999;
291
+ return sprite;
292
+ }
293
+
294
+ /** Trace a rounded-rect path (fill applied by caller). @private */
295
+ }, {
296
+ key: "_roundRect",
297
+ value: function _roundRect(ctx, x, y, w, h, r) {
298
+ var rr = Math.min(r, w / 2, h / 2);
299
+ ctx.beginPath();
300
+ ctx.moveTo(x + rr, y);
301
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
302
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
303
+ ctx.arcTo(x, y + h, x, y, rr);
304
+ ctx.arcTo(x, y, x + w, y, rr);
305
+ ctx.closePath();
306
+ }
307
+
308
+ /**
309
+ * Remove the panel, its labels, and its registered behaviors (scene reload).
310
+ * @private
311
+ */
312
+ }, {
313
+ key: "_teardown",
314
+ value: function _teardown() {
315
+ this._labels = [];
316
+ if (this._panelId) {
317
+ var _this$sceneViewer4;
318
+ (_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);
319
+ }
320
+ if (this._panel) {
321
+ var _this$sceneViewer5;
322
+ (_this$sceneViewer5 = this.sceneViewer) === null || _this$sceneViewer5 === void 0 || (_this$sceneViewer5 = _this$sceneViewer5.scene) === null || _this$sceneViewer5 === void 0 || _this$sceneViewer5.remove(this._panel);
323
+ this._panel.traverse(function (obj) {
324
+ if (obj.isMesh || obj.isSprite) {
325
+ var _obj$geometry, _obj$geometry$dispose;
326
+ (_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);
327
+ var mats = Array.isArray(obj.material) ? obj.material : [obj.material];
328
+ mats.forEach(function (m) {
329
+ var _m$map, _m$map$dispose, _m$dispose;
330
+ 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
331
+ m === null || m === void 0 || (_m$dispose = m.dispose) === null || _m$dispose === void 0 || _m$dispose.call(m);
332
+ });
333
+ }
334
+ });
335
+ this._panel = null;
336
+ }
337
+ this._panelId = null;
338
+ }
339
+ }, {
340
+ key: "_doDispose",
341
+ value: function _doDispose() {
342
+ var _this$sceneViewer6;
343
+ if ((_this$sceneViewer6 = this.sceneViewer) !== null && _this$sceneViewer6 !== void 0 && _this$sceneViewer6.off) {
344
+ this.sceneViewer.off('scene-loaded', this._build);
345
+ }
346
+ this._teardown();
347
+ this.sceneViewer = null;
348
+ }
349
+ }]);
350
+ }(BaseDisposable);
351
+
352
+ export { ControlPanelManager, computeSwitchRowX };