iobroker.mywebui 1.42.16 → 1.42.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/io-package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "mywebui",
4
- "version": "1.42.16",
4
+ "version": "1.42.17",
5
5
  "titleLang": {
6
6
  "en": "mywebui",
7
7
  "de": "mywebui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.mywebui",
3
- "version": "1.42.16",
3
+ "version": "1.42.17",
4
4
  "description": "ioBroker mywebui - Custom edited mywebui by gokturk413 with 3D Editor",
5
5
  "type": "module",
6
6
  "main": "dist/backend/main.js",
@@ -6,8 +6,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
6
6
  static template = html`
7
7
  <div id="root" style="width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;background:#1e1e1e;font-family:'Segoe UI',sans-serif;font-size:12px;">
8
8
 
9
- <!-- Toolbar - realvirtual style dark bar -->
10
- <div style="height:40px;background:#252526;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;padding:0 8px;gap:4px;">
9
+ <!-- Toolbar -->
10
+ <div style="height:40px;background:#252526;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0;">
11
11
  <span style="color:#ccc;font-size:11px;font-weight:bold;margin-right:8px;letter-spacing:0.5px;">3D EDITOR</span>
12
12
  <button id="saveBtn" class="tb-btn primary">&#128190; Save</button>
13
13
  <div class="tb-sep"></div>
@@ -22,44 +22,62 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
22
22
  </div>
23
23
 
24
24
  <!-- 3-column body -->
25
- <div style="flex:1;display:flex;overflow:hidden;">
25
+ <div style="flex:1;display:flex;overflow:hidden;min-height:0;">
26
26
 
27
- <!-- LEFT: Scene tree (realvirtual-style) -->
28
- <div id="leftPanel" style="width:220px;min-width:160px;background:#252526;border-right:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
29
- <!-- Tab row: All / Assets / Lights / Signals -->
30
- <div style="display:flex;background:#2d2d2d;border-bottom:1px solid #3c3c3c;">
27
+ <!-- LEFT: Scene tree + Library -->
28
+ <div id="leftPanel" style="width:240px;min-width:160px;background:#252526;border-right:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
29
+ <!-- Tab row: All / Assets / Lights / Signals / Library -->
30
+ <div style="display:flex;background:#2d2d2d;border-bottom:1px solid #3c3c3c;flex-shrink:0;">
31
31
  <button id="tabAll" class="lt-tab active" data-tab="all">All</button>
32
32
  <button id="tabAssets" class="lt-tab" data-tab="assets">Assets</button>
33
33
  <button id="tabLights" class="lt-tab" data-tab="lights">Lights</button>
34
34
  <button id="tabSignals" class="lt-tab" data-tab="signals">Signals</button>
35
+ <button id="tabLibrary" class="lt-tab" data-tab="library" style="color:#9cdcfe;" title="3D Components Library">Lib</button>
35
36
  </div>
36
37
  <!-- Search -->
37
- <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;">
38
- <input id="treeSearch" type="text" placeholder="Search nodes..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
38
+ <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;flex-shrink:0;">
39
+ <input id="treeSearch" type="text" placeholder="Search..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
39
40
  </div>
40
- <!-- Tree content -->
41
+ <!-- Tree / Library content -->
41
42
  <div id="treeContent" style="flex:1;overflow:auto;padding:4px 0;"></div>
43
+ <!-- Library quick-add button (shown in library tab) -->
44
+ <div id="libAddRow" style="display:none;padding:4px 6px;border-top:1px solid #3c3c3c;flex-shrink:0;">
45
+ <button id="addLibraryBtn" class="tb-btn" style="width:100%;font-size:10px;">&#43; Import GLB as Component</button>
46
+ </div>
42
47
  </div>
43
48
 
44
49
  <!-- CENTER: Viewport -->
45
- <div id="viewport" style="flex:1;position:relative;overflow:hidden;background:#333;"></div>
46
-
47
- <!-- RIGHT: Component Library (realvirtual-style) -->
48
- <div id="rightPanel" style="width:220px;min-width:160px;background:#252526;border-left:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
49
- <!-- Library header -->
50
- <div style="padding:6px 8px;background:#2d2d2d;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;justify-content:space-between;">
51
- <span style="color:#9cdcfe;font-size:11px;font-weight:bold;letter-spacing:0.5px;">&#128218; LIBRARY</span>
52
- <button id="addLibraryBtn" class="tb-btn" style="padding:2px 8px;font-size:10px;">&#43; Add</button>
53
- </div>
54
- <!-- Search library -->
55
- <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;">
56
- <input id="libSearch" type="text" placeholder="Search..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
50
+ <div id="viewport" style="flex:1;position:relative;overflow:hidden;background:#333;">
51
+ <!-- Loading overlay -->
52
+ <div id="loadOverlay" style="position:absolute;inset:0;background:#1e1e1e;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:100;transition:opacity 0.3s;">
53
+ <div style="color:#9cdcfe;font-size:16px;font-weight:bold;margin-bottom:16px;letter-spacing:1px;">3D EDITOR</div>
54
+ <div style="width:220px;height:4px;background:#3c3c3c;border-radius:2px;overflow:hidden;margin-bottom:10px;">
55
+ <div id="loadBarFill" style="height:100%;background:linear-gradient(90deg,#0e639c,#4ec9b0);width:0%;transition:width 0.4s ease;"></div>
56
+ </div>
57
+ <div id="loadText" style="color:#666;font-size:11px;">Initializing...</div>
57
58
  </div>
58
- <!-- Thumbnail grid -->
59
- <div id="libraryGrid" style="flex:1;overflow:auto;padding:6px;"></div>
60
59
  </div>
61
60
 
62
61
  </div>
62
+
63
+ <!-- Bottom: Script / Style editor panel -->
64
+ <div id="bottomPanel" style="height:0;flex-shrink:0;background:#1e1e1e;border-top:2px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;transition:height 0.15s;">
65
+ <div style="display:flex;background:#252526;border-bottom:1px solid #3c3c3c;align-items:center;flex-shrink:0;">
66
+ <button id="btTabScript" class="bt-tab active" data-btab="script">JS Script</button>
67
+ <button id="btTabStyle" class="bt-tab" data-btab="style">CSS Style</button>
68
+ <div style="flex:1;"></div>
69
+ <span style="color:#555;font-size:10px;margin-right:6px;">Ctrl+Enter = Run</span>
70
+ <button id="runScriptBtn" class="tb-btn" style="padding:2px 10px;font-size:10px;margin-right:4px;">&#9654; Run</button>
71
+ <button id="scriptErrBtn" id="scriptErrBtn" style="display:none;padding:2px 8px;font-size:10px;background:#5a1a1a;color:#f44747;border:1px solid #8a2a2a;border-radius:3px;cursor:pointer;margin-right:4px;" title="Script error"></button>
72
+ </div>
73
+ <textarea id="scriptEditor" spellcheck="false" placeholder="// Three.js API available:&#10;// scene, camera, THREE, renderer, controls&#10;// assets → Map(name → Object3D)&#10;// mixers → Map(assetId → {mixer, clips, actions})&#10;// driveEngine, subscribe(signal, cb), setState(signal, val)&#10;// onFrame(fn) → called every animation frame(dt, timestamp)&#10;// onSelect(fn) → called when node selected(node, assetData)&#10;&#10;// Example:&#10;// const robot = assets.get('Robot');&#10;// onFrame(dt => { robot.rotation.y += dt; });"></textarea>
74
+ <textarea id="styleEditor" spellcheck="false" placeholder="/* CSS for overlay elements */&#10;/* .label-3d { color: white; background: rgba(0,0,0,0.6); } */"></textarea>
75
+ </div>
76
+ <!-- Bottom panel toggle (draggable handle) -->
77
+ <div id="bottomHandle" style="height:6px;background:#3c3c3c;cursor:ns-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;">
78
+ <div style="width:40px;height:2px;background:#555;border-radius:1px;"></div>
79
+ </div>
80
+
63
81
  </div>
64
82
  `;
65
83
 
@@ -149,9 +167,57 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
149
167
  text-align: center;
150
168
  }
151
169
  .lib-card:hover { border-color:#9cdcfe; background:#094771; }
152
- .lib-card .lib-thumb { font-size:32px; margin-bottom:4px; line-height:1; }
170
+ .lib-card .lib-thumb { font-size:28px; margin-bottom:4px; line-height:1; }
153
171
  .lib-card .lib-name { color:#ccc; font-size:10px; word-break:break-word; line-height:1.2; }
154
172
  .lib-card .lib-type { color:#666; font-size:9px; margin-top:2px; }
173
+
174
+ .bt-tab {
175
+ padding: 5px 12px;
176
+ background: transparent;
177
+ color: #888;
178
+ border: none;
179
+ border-bottom: 2px solid transparent;
180
+ cursor: pointer;
181
+ font-size: 11px;
182
+ font-weight: bold;
183
+ }
184
+ .bt-tab:hover { color:#ccc; }
185
+ .bt-tab.active { color:#9cdcfe; border-bottom-color:#9cdcfe; }
186
+
187
+ #scriptEditor, #styleEditor {
188
+ flex: 1;
189
+ width: 100%;
190
+ background: #1e1e1e;
191
+ color: #d4d4d4;
192
+ border: none;
193
+ resize: none;
194
+ font-family: 'Consolas','Courier New',monospace;
195
+ font-size: 12px;
196
+ line-height: 1.5;
197
+ padding: 8px 12px;
198
+ box-sizing: border-box;
199
+ outline: none;
200
+ tab-size: 4;
201
+ display: none;
202
+ }
203
+ #scriptEditor.active, #styleEditor.active { display: block; }
204
+
205
+ .lib-item {
206
+ display: flex;
207
+ align-items: center;
208
+ padding: 5px 8px;
209
+ color: #ccc;
210
+ cursor: pointer;
211
+ border-left: 2px solid transparent;
212
+ gap: 6px;
213
+ }
214
+ .lib-item:hover { background:#2d2d2d; border-left-color:#9cdcfe; }
215
+ .lib-item .lib-icon { font-size:16px; flex-shrink:0; }
216
+ .lib-item .lib-info { flex:1; overflow:hidden; }
217
+ .lib-item .lib-name { font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
218
+ .lib-item .lib-meta { font-size:9px; color:#555; }
219
+ .lib-item .lib-add { opacity:0; font-size:11px; color:#4ec9b0; padding:0 4px; }
220
+ .lib-item:hover .lib-add { opacity:1; }
155
221
  `;
156
222
 
157
223
  constructor() {
@@ -175,6 +241,9 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
175
241
  this._threeObjects = [];
176
242
  this._mixers = new Map(); // assetId → { mixer, clips, actions }
177
243
  this._lastAnimTs = null;
244
+ this._scriptFrameCallbacks = [];
245
+ this._scriptSelectCallbacks = [];
246
+ this._scriptUnsubs = [];
178
247
  }
179
248
 
180
249
  async connectedCallback() {
@@ -183,25 +252,53 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
183
252
 
184
253
  async init() {
185
254
  try {
186
- this._setStatus('Loading Three.js...');
187
- const THREE = await import('/mywebui.0.widgets/node_modules/three/build/three.module.js');
188
- const { OrbitControls } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js');
189
- const { GLTFLoader } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js');
255
+ this._setLoadProgress(5, 'Loading Three.js...');
256
+ // Parallel import - 2-3x faster than sequential
257
+ const [THREE, { OrbitControls }, { GLTFLoader }] = await Promise.all([
258
+ import('/mywebui.0.widgets/node_modules/three/build/three.module.js'),
259
+ import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js'),
260
+ import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js'),
261
+ ]);
190
262
  this.THREE = THREE;
191
263
  this.gltfLoader = new GLTFLoader();
192
- this._setStatus('Initializing...');
264
+
265
+ this._setLoadProgress(40, 'Setting up renderer...');
193
266
  this.initThreeJS(THREE, OrbitControls);
267
+
268
+ this._setLoadProgress(65, 'Loading scene...');
194
269
  const sceneName = this.getAttribute('scene-name') || 'new-scene';
195
270
  await this.loadScene(sceneName);
196
- this._setStatus('Ready');
197
- setTimeout(() => this._setStatus(''), 2000);
271
+
272
+ this._setLoadProgress(90, 'Starting...');
198
273
  this.setupEventListeners();
274
+ this._setupBottomPanel();
275
+ this._runUserScript();
276
+
277
+ this._setLoadProgress(100, 'Ready');
278
+ this._setStatus('Ready');
279
+ setTimeout(() => { this._setStatus(''); this._hideLoadOverlay(); }, 400);
199
280
  } catch (err) {
200
281
  console.error('3D Editor init failed:', err);
282
+ this._setLoadProgress(0, 'Error: ' + err.message);
201
283
  this._setStatus('Error: ' + err.message);
202
284
  }
203
285
  }
204
286
 
287
+ _setLoadProgress(pct, msg) {
288
+ const fill = this._getDomElement('loadBarFill');
289
+ const text = this._getDomElement('loadText');
290
+ if (fill) fill.style.width = pct + '%';
291
+ if (text) text.textContent = msg;
292
+ }
293
+
294
+ _hideLoadOverlay() {
295
+ const overlay = this._getDomElement('loadOverlay');
296
+ if (overlay) {
297
+ overlay.style.opacity = '0';
298
+ setTimeout(() => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }, 350);
299
+ }
300
+ }
301
+
205
302
  _setStatus(msg) {
206
303
  const el = this._getDomElement('statusLabel');
207
304
  if (el) el.textContent = msg;
@@ -256,6 +353,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
256
353
  this._lastAnimTs = ts;
257
354
  if (this.driveEngine) this.driveEngine.tick(dt);
258
355
  for (const { mixer } of this._mixers.values()) mixer.update(dt);
356
+ // User script frame callbacks
357
+ for (const fn of this._scriptFrameCallbacks) {
358
+ try { fn(dt, ts); } catch (_) {}
359
+ }
259
360
  this.controls.update();
260
361
  this.renderer.render(this.scene, this.camera);
261
362
  };
@@ -280,6 +381,9 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
280
381
  try {
281
382
  this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
282
383
  if (!this.sceneData) { console.warn('Scene not found:', sceneName); return; }
384
+ // Ensure script/style fields exist
385
+ if (!this.sceneData.script) this.sceneData.script = '';
386
+ if (!this.sceneData.style) this.sceneData.style = '';
283
387
  console.log('Scene loaded:', sceneName);
284
388
  this._buildSceneView();
285
389
  } catch (err) {
@@ -430,6 +534,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
430
534
  this._expandedAssets.add(assetRoot.userData.assetData?.id);
431
535
  this.refreshTree();
432
536
  this._showNodeProperties(node, assetRoot);
537
+ // User script select callbacks
538
+ for (const fn of this._scriptSelectCallbacks) {
539
+ try { fn(node, assetRoot.userData.assetData); } catch (_) {}
540
+ }
433
541
  }
434
542
 
435
543
  selectObject(obj) {
@@ -546,11 +654,152 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
546
654
  return result;
547
655
  }
548
656
 
657
+ // ── Bottom Script/Style panel ─────────────────────────
658
+
659
+ _setupBottomPanel() {
660
+ const root = this._getDomElement('root');
661
+ const panel = this._getDomElement('bottomPanel');
662
+ const handle = this._getDomElement('bottomHandle');
663
+ const scriptTA = this._getDomElement('scriptEditor');
664
+ const styleTA = this._getDomElement('styleEditor');
665
+
666
+ // Populate from sceneData
667
+ if (scriptTA && this.sceneData?.script) scriptTA.value = this.sceneData.script;
668
+ if (styleTA && this.sceneData?.style) styleTA.value = this.sceneData.style;
669
+
670
+ // Set initial active editor
671
+ if (scriptTA) scriptTA.classList.add('active');
672
+
673
+ // Tab switching
674
+ ['btTabScript','btTabStyle'].forEach(id => {
675
+ const btn = this._getDomElement(id);
676
+ if (!btn) return;
677
+ btn.addEventListener('click', () => {
678
+ this.shadowRoot.querySelectorAll('.bt-tab').forEach(b => b.classList.remove('active'));
679
+ btn.classList.add('active');
680
+ const tab = btn.dataset.btab;
681
+ if (scriptTA) scriptTA.classList.toggle('active', tab === 'script');
682
+ if (styleTA) styleTA.classList.toggle('active', tab === 'style');
683
+ });
684
+ });
685
+
686
+ // Run button
687
+ this._getDomElement('runScriptBtn')?.addEventListener('click', () => this._runUserScript());
688
+
689
+ // Ctrl+Enter to run while editing
690
+ scriptTA?.addEventListener('keydown', (e) => {
691
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); this._runUserScript(); }
692
+ });
693
+
694
+ // Auto-save script/style to sceneData on change
695
+ scriptTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.script = scriptTA.value; });
696
+ styleTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.style = styleTA.value; this._applyUserStyle(); });
697
+
698
+ // Draggable resize handle
699
+ let dragStart = null;
700
+ let startH = 0;
701
+ handle?.addEventListener('mousedown', (e) => {
702
+ dragStart = e.clientY;
703
+ startH = panel ? panel.offsetHeight : 0;
704
+ // If panel collapsed, open it
705
+ if (startH < 20) startH = 0;
706
+ document.addEventListener('mousemove', onDrag);
707
+ document.addEventListener('mouseup', onUp);
708
+ });
709
+ const onDrag = (e) => {
710
+ if (dragStart === null) return;
711
+ const delta = dragStart - e.clientY;
712
+ const h = Math.max(0, Math.min(startH + delta, 500));
713
+ if (panel) panel.style.height = h + 'px';
714
+ };
715
+ const onUp = () => { dragStart = null; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', onUp); };
716
+
717
+ // Click handle to toggle
718
+ handle?.addEventListener('click', (e) => {
719
+ if (!panel) return;
720
+ const h = panel.offsetHeight;
721
+ panel.style.height = h < 20 ? '200px' : '0';
722
+ });
723
+ }
724
+
725
+ _applyUserStyle() {
726
+ const styleTA = this._getDomElement('styleEditor');
727
+ const css = styleTA?.value || this.sceneData?.style || '';
728
+ let styleEl = this.shadowRoot.getElementById('userStyle3d');
729
+ if (!styleEl) {
730
+ styleEl = document.createElement('style');
731
+ styleEl.id = 'userStyle3d';
732
+ this.shadowRoot.appendChild(styleEl);
733
+ }
734
+ styleEl.textContent = css;
735
+ }
736
+
737
+ _runUserScript() {
738
+ // Clear previous script state
739
+ for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
740
+ this._scriptUnsubs = [];
741
+ this._scriptFrameCallbacks = [];
742
+ this._scriptSelectCallbacks = [];
743
+
744
+ const scriptTA = this._getDomElement('scriptEditor');
745
+ const errBtn = this._getDomElement('scriptErrBtn');
746
+ const script = scriptTA?.value || this.sceneData?.script || '';
747
+ if (!script.trim()) return;
748
+
749
+ // Build assets map: name/id → Three.js Object3D
750
+ const assets = new Map();
751
+ for (const obj of this.scene.children) {
752
+ if (obj.userData.assetData) {
753
+ assets.set(obj.userData.assetData.name, obj);
754
+ assets.set(obj.userData.assetData.id, obj);
755
+ }
756
+ }
757
+
758
+ const self = this;
759
+ const ctx = {
760
+ THREE: this.THREE,
761
+ scene: this.scene,
762
+ camera: this.camera,
763
+ renderer: this.renderer,
764
+ controls: this.controls,
765
+ assets,
766
+ mixers: this._mixers,
767
+ driveEngine: this.driveEngine,
768
+ subscribe(signal, cb) {
769
+ const unsub = self.driveEngine.bus.subscribe(signal, cb);
770
+ self._scriptUnsubs.push(unsub);
771
+ return unsub;
772
+ },
773
+ setState(signal, val) { iobrokerHandler.setState(signal, val); },
774
+ getState(signal) { return self.driveEngine.bus.get(signal); },
775
+ onFrame(fn) { self._scriptFrameCallbacks.push(fn); },
776
+ onSelect(fn) { self._scriptSelectCallbacks.push(fn); },
777
+ log(...args) { console.log('[3D Script]', ...args); },
778
+ };
779
+
780
+ if (errBtn) errBtn.style.display = 'none';
781
+ try {
782
+ const fn = new Function(...Object.keys(ctx), script);
783
+ fn(...Object.values(ctx));
784
+ } catch (err) {
785
+ console.error('[3D Script] Error:', err);
786
+ if (errBtn) { errBtn.style.display = ''; errBtn.textContent = '⚠ ' + err.message; }
787
+ }
788
+ }
789
+
549
790
  // ── Scene tree (left panel) ───────────────────────────
550
791
 
551
792
  refreshTree() {
552
793
  const el = this._getDomElement('treeContent');
553
- if (!el || !this.sceneData) return;
794
+ if (!el) return;
795
+
796
+ // Library tab - show reusable 3D components
797
+ if (this._activeTab === 'library') {
798
+ this._renderLibraryTab(el);
799
+ return;
800
+ }
801
+
802
+ if (!this.sceneData) return;
554
803
  const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
555
804
  el.innerHTML = '';
556
805
 
@@ -714,42 +963,80 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
714
963
  }
715
964
  }
716
965
 
717
- // ── Library (right panel) ─────────────────────────────
966
+ // ── Library (right panel - kept for backward compat) ───
718
967
 
719
968
  refreshLibrary() {
720
969
  const el = this._getDomElement('libraryGrid');
721
970
  if (!el || !this.sceneData) return;
722
- const search = (this._getDomElement('libSearch')?.value ?? '').toLowerCase();
723
- const assets = (Array.isArray(this.sceneData.assets) ? this.sceneData.assets : [])
724
- .filter(a => !search || a.name.toLowerCase().includes(search));
971
+ // Library now lives primarily in left panel tab
972
+ // Right panel libraryGrid kept but hidden
973
+ }
974
+
975
+ // ── Library tab in left panel ─────────────────────────
976
+
977
+ _renderLibraryTab(el) {
978
+ el.innerHTML = '';
979
+ const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
980
+ const assets = Array.isArray(this.sceneData?.assets) ? this.sceneData.assets : [];
981
+ const filtered = assets.filter(a => !search || a.name.toLowerCase().includes(search));
725
982
 
726
- if (assets.length === 0) {
727
- el.innerHTML = `<div style="color:#555;text-align:center;padding:20px 8px;font-size:11px;line-height:1.6;">
728
- No models<br><br>Click <b style="color:#9cdcfe">+ Add</b> to load<br>a GLB/GLTF file
983
+ // Show/hide add button
984
+ const addRow = this._getDomElement('libAddRow');
985
+ if (addRow) addRow.style.display = '';
986
+
987
+ if (filtered.length === 0) {
988
+ el.innerHTML = `<div style="color:#555;padding:16px 8px;font-size:11px;text-align:center;line-height:1.8;">
989
+ No 3D components yet.<br>
990
+ <span style="color:#9cdcfe;">Import GLB</span> to create<br>a reusable component.
729
991
  </div>`;
730
992
  return;
731
993
  }
732
994
 
733
- el.style.display = 'grid';
734
- el.style.gridTemplateColumns = '1fr 1fr';
735
- el.style.gap = '6px';
736
- el.innerHTML = '';
737
-
738
- for (const asset of assets) {
739
- const card = document.createElement('div');
740
- card.className = 'lib-card';
995
+ for (const asset of filtered) {
741
996
  const shortName = asset.name.replace(/\.(glb|gltf)$/i, '');
742
- card.innerHTML = `
743
- <div class="lib-thumb">📦</div>
744
- <div class="lib-name">${shortName.substring(0, 20)}</div>
745
- <div class="lib-type">GLB</div>
997
+ const driveCnt = asset.drives?.length ?? 0;
998
+ const clipCnt = asset.availableClips?.length ?? 0;
999
+ const interCnt = Object.keys(asset.interactions ?? {}).length;
1000
+ const meta = [
1001
+ driveCnt ? `${driveCnt}D` : '',
1002
+ clipCnt ? `${clipCnt}A` : '',
1003
+ interCnt ? `${interCnt}I` : '',
1004
+ ].filter(Boolean).join(' · ') || 'GLB';
1005
+
1006
+ const item = document.createElement('div');
1007
+ item.className = 'lib-item';
1008
+ item.innerHTML = `
1009
+ <span class="lib-icon">📦</span>
1010
+ <div class="lib-info">
1011
+ <div class="lib-name" title="${asset.name}">${shortName}</div>
1012
+ <div class="lib-meta">${meta}</div>
1013
+ </div>
1014
+ <span class="lib-add" title="Add instance to scene">+</span>
746
1015
  `;
747
- card.title = asset.name;
748
- card.addEventListener('click', () => this._selectAsset(asset));
749
- el.appendChild(card);
1016
+ item.querySelector('.lib-add')?.addEventListener('click', (e) => {
1017
+ e.stopPropagation();
1018
+ this._addAssetInstance(asset);
1019
+ });
1020
+ item.addEventListener('click', () => this._selectAsset(asset));
1021
+ el.appendChild(item);
750
1022
  }
751
1023
  }
752
1024
 
1025
+ _addAssetInstance(templateAsset) {
1026
+ // Duplicate the asset as a new instance in the scene
1027
+ const newId = 'asset_' + Date.now().toString(36);
1028
+ const instance = JSON.parse(JSON.stringify(templateAsset));
1029
+ instance.id = newId;
1030
+ instance.name = templateAsset.name + '_' + newId.slice(-4);
1031
+ instance.position = { x: Math.random() * 2 - 1, y: 0, z: Math.random() * 2 - 1 };
1032
+ if (!this.sceneData.assets) this.sceneData.assets = [];
1033
+ this.sceneData.assets.push(instance);
1034
+ this.loadAsset(instance);
1035
+ this.refreshTree();
1036
+ this._setStatus('Instance added: ' + instance.name);
1037
+ setTimeout(() => this._setStatus(''), 2000);
1038
+ }
1039
+
753
1040
  // ── File input ────────────────────────────────────────
754
1041
 
755
1042
  onFileSelected(event) {
@@ -839,19 +1126,25 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
839
1126
  this._getDomElement('gridToggle').addEventListener('click', () => this.toggleGrid());
840
1127
  this._getDomElement('axesToggle').addEventListener('click', () => this.toggleAxes());
841
1128
 
842
- // Tree tabs
843
- ['tabAll','tabAssets','tabLights','tabSignals'].forEach(id => {
844
- this._getDomElement(id).addEventListener('click', (e) => {
1129
+ // Tree tabs (including Library)
1130
+ ['tabAll','tabAssets','tabLights','tabSignals','tabLibrary'].forEach(id => {
1131
+ const el = this._getDomElement(id);
1132
+ if (!el) return;
1133
+ el.addEventListener('click', (e) => {
845
1134
  this._activeTab = e.target.dataset.tab;
846
1135
  this.shadowRoot.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
847
1136
  e.target.classList.add('active');
1137
+ const addRow = this._getDomElement('libAddRow');
1138
+ if (addRow) addRow.style.display = this._activeTab === 'library' ? '' : 'none';
848
1139
  this.refreshTree();
849
1140
  });
850
1141
  });
851
1142
 
1143
+ // Library add button
1144
+ this._getDomElement('addLibraryBtn')?.addEventListener('click', () => this._getDomElement('fileInput').click());
1145
+
852
1146
  // Search
853
- this._getDomElement('treeSearch').addEventListener('input', () => this.refreshTree());
854
- this._getDomElement('libSearch').addEventListener('input', () => this.refreshLibrary());
1147
+ this._getDomElement('treeSearch')?.addEventListener('input', () => this.refreshTree());
855
1148
  }
856
1149
 
857
1150
  disconnectedCallback() {
@@ -859,6 +1152,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
859
1152
  if (this.driveEngine) this.driveEngine.destroy();
860
1153
  for (const { mixer } of this._mixers.values()) mixer.stopAllAction();
861
1154
  this._mixers.clear();
1155
+ for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
1156
+ this._scriptUnsubs = [];
862
1157
  if (this.renderer) {
863
1158
  this.renderer.dispose();
864
1159
  this.renderer.domElement?.remove();