iobroker.mywebui 1.42.17 → 1.42.19

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.17",
4
+ "version": "1.42.19",
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.17",
3
+ "version": "1.42.19",
4
4
  "description": "ioBroker mywebui - Custom edited mywebui by gokturk413 with 3D Editor",
5
5
  "type": "module",
6
6
  "main": "dist/backend/main.js",
@@ -37,6 +37,7 @@ export class IobrokerHandler {
37
37
  this.#cache.set('screen', new Map());
38
38
  this.#cache.set('control', new Map());
39
39
  this.#cache.set('3dscreen', new Map());
40
+ this.#cache.set('3dcontrol', new Map());
40
41
  }
41
42
  getNormalizedSignalName(id, relativeSignalPath, element) {
42
43
  return (relativeSignalPath ?? '') + id;
@@ -208,6 +209,8 @@ export class IobrokerHandler {
208
209
  return this.getCustomControl(name);
209
210
  else if (type == '3dscreen')
210
211
  return this.get3DScreen(name);
212
+ else if (type == '3dcontrol')
213
+ return this.get3DControl(name);
211
214
  return null;
212
215
  }
213
216
  async getScreen(name) {
@@ -244,6 +247,23 @@ export class IobrokerHandler {
244
247
  }
245
248
  return scene;
246
249
  }
250
+ async get3DControl(name) {
251
+ if (name[0] == '/')
252
+ name = name.substring(1);
253
+ let ctrl = this.#cache.get('3dcontrol').get(name);
254
+ if (!ctrl) {
255
+ if (this._readyPromises)
256
+ await this.waitForReady();
257
+ try {
258
+ ctrl = await this._getObjectFromFile(this.configPath + "3dcontrols/" + name + '.3dcontrol');
259
+ }
260
+ catch (err) {
261
+ console.error("Error reading 3D Control", ctrl, err);
262
+ }
263
+ this.#cache.get('3dcontrol').set(name, ctrl);
264
+ }
265
+ return ctrl;
266
+ }
247
267
  async saveObject(type, name, data) {
248
268
  await this._saveObjectToFile(data, "/" + this.configPath + type + "s/" + name + '.' + type);
249
269
  if (this.#cache.has(type))
@@ -1,5 +1,6 @@
1
1
  import { iobrokerHandler } from '../common/IobrokerHandler.js';
2
2
  import { IobrokerWebuiScreenEditor, defaultNewStyle, defaultNewControlScript } from './IobrokerWebuiScreenEditor.js';
3
+ import { IobrokerWebui3DScreenEditor } from './IobrokerWebui3DScreenEditor.js';
3
4
  export class CommandHandling {
4
5
  dockManager;
5
6
  iobrokerWebuiAppShell;
@@ -17,6 +18,10 @@ export class CommandHandling {
17
18
  if (target instanceof IobrokerWebuiScreenEditor) {
18
19
  window.open("runtime.html#screenName=" + target.name);
19
20
  }
21
+ else if (target instanceof IobrokerWebui3DScreenEditor) {
22
+ const scName = target.sceneData?.name || target.getAttribute?.('scene-name') || '';
23
+ window.open("runtime.html#3dscreen=" + encodeURIComponent(scName));
24
+ }
20
25
  else {
21
26
  window.open("runtime.html");
22
27
  }
@@ -364,6 +364,7 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
364
364
  this._stopAnimation = () => { animating = false; };
365
365
 
366
366
  this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
367
+ this._setupHoverDetection();
367
368
  const resizeObs = new ResizeObserver(() => this.onViewportResize());
368
369
  resizeObs.observe(viewport);
369
370
  }
@@ -379,10 +380,13 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
379
380
 
380
381
  async loadScene(sceneName) {
381
382
  try {
382
- this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
383
+ const sceneType = this.getAttribute('scene-type') || '3dscreen';
384
+ this.sceneData = sceneType === '3dcontrol'
385
+ ? await iobrokerHandler.getWebuiObject('3dcontrol', sceneName)
386
+ : await iobrokerHandler.get3DScreen(sceneName);
383
387
  if (!this.sceneData) { console.warn('Scene not found:', sceneName); return; }
384
- // Ensure script/style fields exist
385
- if (!this.sceneData.script) this.sceneData.script = '';
388
+ // Ensure script/style fields exist with default boilerplate
389
+ if (!this.sceneData.script) this.sceneData.script = `// Available: THREE, scene, camera, renderer, controls\n// assets (Map<name, Object3D>) mixers driveEngine\n// setState(signal, val) getState(signal) subscribe(signal, cb)\n// onFrame(fn) onSelect(fn) log(...)\n\nonFrame((dt, ts) => {\n // Runs every animation frame. dt = delta seconds.\n});\n`;
386
390
  if (!this.sceneData.style) this.sceneData.style = '';
387
391
  console.log('Scene loaded:', sceneName);
388
392
  this._buildSceneView();
@@ -447,6 +451,11 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
447
451
  this._threeObjects.push(model);
448
452
  if (this.driveEngine && Array.isArray(this.sceneData?.assets)) {
449
453
  this.driveEngine.createFromAssets(this.sceneData.assets, this._threeObjects);
454
+ // Wrap drives for AtMax/AtMin events
455
+ if (asset.drives?.length) {
456
+ const drives = asset.drives.map((_, i) => this.driveEngine.getDrive(asset.id, i));
457
+ this._wrapDriveEvents(asset, drives);
458
+ }
450
459
  }
451
460
  // AnimationMixer for built-in GLB animations
452
461
  if (gltf.animations?.length > 0) {
@@ -457,6 +466,12 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
457
466
  }
458
467
  this._mixers.set(asset.id, { mixer, clips: gltf.animations, actions });
459
468
  asset.availableClips = gltf.animations.map(c => c.name);
469
+ // animationEnd event
470
+ mixer.addEventListener('finished', (e) => {
471
+ const clipName = e.action.getClip().name;
472
+ this._executeNodeEvent(model, asset, `animationEnd:${clipName}`);
473
+ this._executeNodeEvent(model, asset, 'animationEnd');
474
+ });
460
475
  }
461
476
  // Extract named node list for scene tree
462
477
  asset._nodeList = this._extractNamedNodes(model);
@@ -512,6 +527,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
512
527
 
513
528
  if (hitNode && hitAssetRoot) {
514
529
  if (hitNode.userData.interaction) this._handleInteraction(hitNode);
530
+ // Fire node event: check by name AND by uuid
531
+ this._executeNodeEvent(hitNode, hitAssetRoot.userData.assetData, 'click');
515
532
  this.selectNode(hitNode, hitAssetRoot);
516
533
  } else {
517
534
  this.deselectAll();
@@ -637,6 +654,131 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
637
654
  }
638
655
  }
639
656
 
657
+ // ── Event execution ───────────────────────────────────
658
+
659
+ /**
660
+ * Execute a node event handler stored in asset.events[nodeName][eventKey].
661
+ * eventKey: 'click' | 'mouseenter' | 'mouseleave' | 'animationEnd' | 'animationEnd:ClipName' |
662
+ * 'driveAtMax:N' | 'driveAtMin:N' | 'signalChange:path'
663
+ * Node name '__root__' = the asset root object.
664
+ */
665
+ _executeNodeEvent(node, asset, eventKey) {
666
+ if (!asset?.events) return;
667
+ const nodeKey = (node?.userData?.assetData === asset || !node?.name) ? '__root__' : node.name;
668
+ const script = asset.events[nodeKey]?.[eventKey];
669
+ if (!script?.trim()) return;
670
+
671
+ const ctx = {
672
+ node,
673
+ assetData: asset,
674
+ THREE: this.THREE,
675
+ scene: this.scene,
676
+ camera: this.camera,
677
+ renderer: this.renderer,
678
+ controls: this.controls,
679
+ assets: this._buildAssetsMap(),
680
+ mixers: this._mixers,
681
+ driveEngine: this.driveEngine,
682
+ setState(signal, val) { iobrokerHandler.setState(signal, val); },
683
+ getState: (s) => this.driveEngine.bus.get(s),
684
+ subscribe: (s, cb) => { const u = this.driveEngine.bus.subscribe(s, cb); this._scriptUnsubs.push(u); return u; },
685
+ log: (...a) => console.log('[3D Event]', nodeKey, eventKey, ...a),
686
+ };
687
+ try {
688
+ new Function(...Object.keys(ctx), script)(...Object.values(ctx));
689
+ } catch (err) {
690
+ console.error(`[3D Event ${nodeKey}:${eventKey}]`, err.message);
691
+ }
692
+ }
693
+
694
+ _buildAssetsMap() {
695
+ const m = new Map();
696
+ for (const obj of this.scene.children) {
697
+ if (obj.userData.assetData) {
698
+ m.set(obj.userData.assetData.name, obj);
699
+ m.set(obj.userData.assetData.id, obj);
700
+ }
701
+ }
702
+ return m;
703
+ }
704
+
705
+ // ── Hover detection ───────────────────────────────────
706
+
707
+ _setupHoverDetection() {
708
+ let hoverNode = null;
709
+ let hoverAsset = null;
710
+ let throttle = null;
711
+
712
+ this.renderer.domElement.addEventListener('mousemove', (e) => {
713
+ if (throttle) return;
714
+ throttle = setTimeout(() => { throttle = null; }, 50); // 20 fps check
715
+
716
+ const rect = this.renderer.domElement.getBoundingClientRect();
717
+ this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
718
+ this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
719
+ this.raycaster.setFromCamera(this.mouse, this.camera);
720
+ const hits = this.raycaster.intersectObjects(this.scene.children, true);
721
+
722
+ let newNode = null, newRoot = null;
723
+ for (const hit of hits) {
724
+ const o = hit.object;
725
+ if (o.userData.isGrid || o.userData.isAxes || o.userData.isSelectionHelper) continue;
726
+ let p = o;
727
+ while (p && p !== this.scene) {
728
+ if (p.userData.assetData) { newRoot = p; break; }
729
+ p = p.parent;
730
+ }
731
+ if (newRoot) { newNode = o; break; }
732
+ }
733
+
734
+ if (newNode !== hoverNode) {
735
+ if (hoverNode && hoverAsset) {
736
+ this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
737
+ this.renderer.domElement.style.cursor = 'default';
738
+ }
739
+ if (newNode && newRoot) {
740
+ const asset = newRoot.userData.assetData;
741
+ this._executeNodeEvent(newNode, asset, 'mouseenter');
742
+ // Change cursor if node has any event or interaction
743
+ const hasEvent = asset?.events || newNode.userData.interaction;
744
+ if (hasEvent) this.renderer.domElement.style.cursor = 'pointer';
745
+ }
746
+ hoverNode = newNode;
747
+ hoverAsset = newRoot?.userData.assetData ?? null;
748
+ }
749
+ });
750
+
751
+ this.renderer.domElement.addEventListener('mouseleave', () => {
752
+ if (hoverNode && hoverAsset) {
753
+ this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
754
+ }
755
+ hoverNode = null; hoverAsset = null;
756
+ this.renderer.domElement.style.cursor = 'default';
757
+ });
758
+ }
759
+
760
+ // ── Drive events (AtMax / AtMin) ──────────────────────
761
+ // Called from DriveEngine after createFromAssets - wraps onAfterUpdate
762
+ _wrapDriveEvents(asset, drives) {
763
+ for (let i = 0; i < drives.length; i++) {
764
+ const drive = drives[i];
765
+ if (!drive) continue;
766
+ const cfg = asset.drives?.[i];
767
+ const origCb = drive.onAfterUpdate;
768
+ drive.onAfterUpdate = (d) => {
769
+ if (origCb) origCb(d);
770
+ const root = this._threeObjects.find(o => o.userData.assetId === asset.id);
771
+ if (!root) return;
772
+ const atMax = cfg?.maxPos !== undefined ? Math.abs(d.currentPosition - cfg.maxPos) < 0.1 : false;
773
+ const atMin = cfg?.minPos !== undefined ? Math.abs(d.currentPosition - cfg.minPos) < 0.1 : false;
774
+ if (atMax && !d._evtWasAtMax) this._executeNodeEvent(root, asset, `driveAtMax:${i}`);
775
+ if (atMin && !d._evtWasAtMin) this._executeNodeEvent(root, asset, `driveAtMin:${i}`);
776
+ d._evtWasAtMax = atMax;
777
+ d._evtWasAtMin = atMin;
778
+ };
779
+ }
780
+ }
781
+
640
782
  _extractNamedNodes(obj, depth = 0, result = []) {
641
783
  if (depth > 7) return result;
642
784
  if (depth > 0 && obj.name) {
@@ -974,51 +1116,99 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
974
1116
 
975
1117
  // ── Library tab in left panel ─────────────────────────
976
1118
 
977
- _renderLibraryTab(el) {
1119
+ async _renderLibraryTab(el) {
978
1120
  el.innerHTML = '';
979
1121
  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));
982
1122
 
983
1123
  // Show/hide add button
984
1124
  const addRow = this._getDomElement('libAddRow');
985
1125
  if (addRow) addRow.style.display = '';
986
1126
 
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.
991
- </div>`;
992
- return;
1127
+ // ── Section 1: Scene-local assets (instances) ──────────────────
1128
+ const assets = Array.isArray(this.sceneData?.assets) ? this.sceneData.assets : [];
1129
+ const filteredAssets = assets.filter(a => !search || a.name.toLowerCase().includes(search));
1130
+
1131
+ const secHdr1 = document.createElement('div');
1132
+ secHdr1.style.cssText = 'padding:4px 8px;color:#888;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;background:#2d2d2d;border-bottom:1px solid #3c3c3c;';
1133
+ secHdr1.textContent = 'Scene Assets';
1134
+ el.appendChild(secHdr1);
1135
+
1136
+ if (filteredAssets.length === 0) {
1137
+ const empty = document.createElement('div');
1138
+ empty.style.cssText = 'color:#555;padding:8px;font-size:11px;text-align:center;';
1139
+ empty.textContent = 'No local assets';
1140
+ el.appendChild(empty);
1141
+ } else {
1142
+ for (const asset of filteredAssets) {
1143
+ const shortName = asset.name.replace(/\.(glb|gltf)$/i, '');
1144
+ const driveCnt = asset.drives?.length ?? 0;
1145
+ const clipCnt = asset.availableClips?.length ?? 0;
1146
+ const interCnt = Object.keys(asset.interactions ?? {}).length;
1147
+ const meta = [driveCnt?`${driveCnt}D`:'', clipCnt?`${clipCnt}A`:'', interCnt?`${interCnt}I`:''].filter(Boolean).join(' · ') || 'GLB';
1148
+ const item = document.createElement('div');
1149
+ item.className = 'lib-item';
1150
+ item.innerHTML = `<span class="lib-icon">📦</span><div class="lib-info"><div class="lib-name" title="${asset.name}">${shortName}</div><div class="lib-meta">${meta}</div></div><span class="lib-add" title="Add instance">+</span>`;
1151
+ item.querySelector('.lib-add')?.addEventListener('click', (e) => { e.stopPropagation(); this._addAssetInstance(asset); });
1152
+ item.addEventListener('click', () => this._selectAsset(asset));
1153
+ el.appendChild(item);
1154
+ }
993
1155
  }
994
1156
 
995
- for (const asset of filtered) {
996
- const shortName = asset.name.replace(/\.(glb|gltf)$/i, '');
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>
1015
- `;
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);
1157
+ // ── Section 2: 3D Custom Controls from backend ─────────────────
1158
+ const secHdr2 = document.createElement('div');
1159
+ secHdr2.style.cssText = 'padding:4px 8px;color:#888;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;background:#2d2d2d;border-bottom:1px solid #3c3c3c;border-top:1px solid #3c3c3c;margin-top:4px;';
1160
+ secHdr2.textContent = '3D Custom Controls';
1161
+ el.appendChild(secHdr2);
1162
+
1163
+ try {
1164
+ const ctrlNames = await iobrokerHandler.getAllNames('3dcontrol', '');
1165
+ const filteredCtrls = ctrlNames.filter(n => !search || n.toLowerCase().includes(search));
1166
+ if (filteredCtrls.length === 0) {
1167
+ const empty = document.createElement('div');
1168
+ empty.style.cssText = 'color:#555;padding:8px;font-size:11px;text-align:center;';
1169
+ empty.textContent = 'No 3D controls yet';
1170
+ el.appendChild(empty);
1171
+ } else {
1172
+ for (const ctrlName of filteredCtrls) {
1173
+ const shortName = ctrlName.replace(/^\/+/, '').split('/').pop();
1174
+ const item = document.createElement('div');
1175
+ item.className = 'lib-item';
1176
+ item.innerHTML = `<span class="lib-icon">🔧</span><div class="lib-info"><div class="lib-name" title="${ctrlName}">${shortName}</div><div class="lib-meta">3D Control</div></div><span class="lib-add" title="Add to scene">+</span>`;
1177
+ item.querySelector('.lib-add')?.addEventListener('click', async (e) => {
1178
+ e.stopPropagation();
1179
+ await this._addControlToScene(ctrlName);
1180
+ });
1181
+ el.appendChild(item);
1182
+ }
1183
+ }
1184
+ } catch(err) {
1185
+ const errDiv = document.createElement('div');
1186
+ errDiv.style.cssText = 'color:#666;padding:8px;font-size:10px;';
1187
+ errDiv.textContent = 'Could not load 3D controls';
1188
+ el.appendChild(errDiv);
1189
+ }
1190
+ }
1191
+
1192
+ async _addControlToScene(ctrlName) {
1193
+ try {
1194
+ const ctrl = await iobrokerHandler.getWebuiObject('3dcontrol', ctrlName);
1195
+ if (!ctrl?.assets?.length) { alert('3D Control has no assets'); return; }
1196
+ const firstAsset = ctrl.assets[0];
1197
+ const newId = 'asset_' + Date.now().toString(36);
1198
+ const instance = JSON.parse(JSON.stringify(firstAsset));
1199
+ instance.id = newId;
1200
+ const shortName = ctrlName.replace(/^\/+/, '').split('/').pop();
1201
+ instance.name = shortName + '_' + newId.slice(-4);
1202
+ instance.position = { x: Math.random() * 4 - 2, y: 0, z: Math.random() * 4 - 2 };
1203
+ if (!this.sceneData.assets) this.sceneData.assets = [];
1204
+ this.sceneData.assets.push(instance);
1205
+ this.loadAsset(instance);
1206
+ this.refreshTree();
1207
+ this._setStatus('Added: ' + instance.name);
1208
+ setTimeout(() => this._setStatus(''), 2000);
1209
+ } catch(err) {
1210
+ console.error('Error adding 3D control to scene:', err);
1211
+ alert('Error adding 3D control: ' + err.message);
1022
1212
  }
1023
1213
  }
1024
1214
 
@@ -1095,7 +1285,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
1095
1285
  async saveScene() {
1096
1286
  try {
1097
1287
  this._setStatus('Saving...');
1098
- const ok = await iobrokerHandler.saveObject('3dscreen', this.sceneData.name, this.sceneData);
1288
+ const sceneType = this.getAttribute('scene-type') || '3dscreen';
1289
+ const ok = await iobrokerHandler.saveObject(sceneType, this.sceneData.name, this.sceneData);
1099
1290
  this._setStatus(ok ? 'Saved ✓' : 'Save failed');
1100
1291
  setTimeout(() => this._setStatus(''), 2000);
1101
1292
  } catch (err) {
@@ -1710,7 +1710,7 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1710
1710
  : `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No drives configured</div>`;
1711
1711
 
1712
1712
  // ── Interaction binding ──
1713
- const nodeKey = nodeName;
1713
+ const nodeKey = (node?.userData?.assetData === asset || !node?.name) ? '__root__' : (node?.name || nodeName);
1714
1714
  const inter = asset?.interactions?.[nodeKey];
1715
1715
  const interHtml = inter
1716
1716
  ? `<div style="background:#1a2a1a;border:1px solid #2a4a2a;border-radius:3px;padding:6px;font-size:10px;">
@@ -1855,6 +1855,142 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1855
1855
  this.show3DNodeProperties(node, asset, mixerInfo, onChange);
1856
1856
  }
1857
1857
  });
1858
+
1859
+ // Show events in native Events dock
1860
+ this._show3DEventsInDock(node, asset, nodeKey, onChange);
1861
+ }
1862
+
1863
+ _escHtml(str) {
1864
+ return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1865
+ }
1866
+
1867
+ _show3DEventsInDock(node, asset, nodeKey, onChange) {
1868
+ const dock = this._getDomElement('eventsDock');
1869
+ if (!dock) return;
1870
+
1871
+ // Get or create the 3D events overlay container
1872
+ let d3 = dock.querySelector('#_3dEventsOverlay');
1873
+ if (!d3) {
1874
+ d3 = document.createElement('div');
1875
+ d3.id = '_3dEventsOverlay';
1876
+ d3.style.cssText = 'width:100%;height:100%;overflow:auto;box-sizing:border-box;background:#1e1e1e;color:#ccc;font-family:"Segoe UI",sans-serif;font-size:12px;';
1877
+ dock.insertBefore(d3, dock.firstChild);
1878
+ }
1879
+ d3.style.display = 'block';
1880
+
1881
+ // Hide native event assignment
1882
+ const nativeEvt = this._getDomElement('eventsList');
1883
+ if (nativeEvt) nativeEvt.style.display = 'none';
1884
+
1885
+ // Build events HTML
1886
+ const nodeEvents = asset?.events?.[nodeKey] ?? {};
1887
+ const nodeEventEntries = Object.entries(nodeEvents);
1888
+ const presetEvents = [
1889
+ { key: 'click', label: '🖱️ click' },
1890
+ { key: 'mouseenter', label: '↗️ mouseenter' },
1891
+ { key: 'mouseleave', label: '↙️ mouseleave' },
1892
+ { key: 'animationEnd', label: '🎬 animationEnd' },
1893
+ ...((asset?.availableClips ?? []).map(c => ({ key: `animationEnd:${c}`, label: `🎬 animationEnd:${c}` }))),
1894
+ ...((asset?.drives ?? []).flatMap((_, i) => [
1895
+ { key: `driveAtMax:${i}`, label: `⬆️ driveAtMax:${i}` },
1896
+ { key: `driveAtMin:${i}`, label: `⬇️ driveAtMin:${i}` }
1897
+ ])),
1898
+ ].filter(p => !nodeEvents[p.key]);
1899
+
1900
+ const evtRowsHtml = nodeEventEntries.length > 0
1901
+ ? nodeEventEntries.map(([evtKey, script]) => `
1902
+ <div style="background:#1a1a2e;border:1px solid #2a2a4a;border-radius:3px;padding:5px;margin-bottom:6px;">
1903
+ <div style="display:flex;align-items:center;gap:4px;margin-bottom:3px;">
1904
+ <span style="color:#dcdcaa;font-size:11px;flex:1;font-family:monospace;">${evtKey}</span>
1905
+ <button class="evt-del-btn" data-evtkey="${evtKey}" style="padding:1px 6px;font-size:10px;background:#5a1a1a;color:#f44747;border:1px solid #8a2a2a;border-radius:3px;cursor:pointer;">✕</button>
1906
+ </div>
1907
+ <textarea class="evt-script" data-evtkey="${evtKey}" rows="4"
1908
+ style="width:100%;box-sizing:border-box;background:#0d0d1a;border:1px solid #3c3c5c;color:#d4d4d4;font-family:'Consolas',monospace;font-size:11px;padding:4px;resize:vertical;line-height:1.4;border-radius:2px;"
1909
+ spellcheck="false"
1910
+ placeholder="// context: node, assetData, THREE, scene&#10;// setState(sig,val) getState(sig)&#10;// assets(Map) mixers driveEngine"
1911
+ >${this._escHtml(script)}</textarea>
1912
+ </div>`).join('')
1913
+ : `<div style="color:#555;font-size:11px;font-style:italic;padding:8px 0;">No events configured</div>`;
1914
+
1915
+ const presetOptsHtml = presetEvents.map(p => `<option value="${p.key}">${p.label}</option>`).join('');
1916
+ const addRowHtml = presetEvents.length > 0
1917
+ ? `<div style="display:flex;gap:4px;margin-top:6px;">
1918
+ <select class="evt-preset-sel" style="flex:1;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 4px;border-radius:3px;font-size:11px;">
1919
+ <option value="">— select event type —</option>
1920
+ ${presetOptsHtml}
1921
+ <option value="__custom__">Custom event name...</option>
1922
+ </select>
1923
+ <button class="evt-add-btn" style="padding:2px 12px;background:#3c3c3c;color:#ccc;border:1px solid #555;border-radius:3px;cursor:pointer;font-size:11px;">+ Add</button>
1924
+ </div>`
1925
+ : `<div style="color:#555;font-size:10px;margin-top:6px;">All standard events configured</div>`;
1926
+
1927
+ d3.innerHTML = `
1928
+ <div style="padding:6px 8px;border-bottom:1px solid #3c3c3c;background:#252526;display:flex;align-items:center;gap:6px;">
1929
+ <span style="color:#dcdcaa;font-size:11px;font-weight:bold;">⚡ 3D Events</span>
1930
+ <span style="color:#555;font-size:10px;">node: ${nodeKey}</span>
1931
+ </div>
1932
+ <div style="padding:8px;">
1933
+ ${evtRowsHtml}
1934
+ ${addRowHtml}
1935
+ <div style="color:#555;font-size:10px;margin-top:8px;line-height:1.6;">
1936
+ Context: <code style="color:#888;">node, assetData, THREE, scene</code><br>
1937
+ <code style="color:#888;">setState(sig,val) · getState(sig)</code><br>
1938
+ <code style="color:#888;">assets(Map) · mixers · driveEngine</code>
1939
+ </div>
1940
+ </div>
1941
+ `;
1942
+
1943
+ // Wire textarea auto-save
1944
+ d3.querySelectorAll('.evt-script').forEach(ta => {
1945
+ ta.addEventListener('blur', () => {
1946
+ const evtKey = ta.dataset.evtkey;
1947
+ if (!asset.events) asset.events = {};
1948
+ if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
1949
+ asset.events[nodeKey][evtKey] = ta.value;
1950
+ if (onChange) onChange(asset);
1951
+ });
1952
+ });
1953
+
1954
+ // Wire delete buttons
1955
+ d3.querySelectorAll('.evt-del-btn').forEach(btn => {
1956
+ btn.addEventListener('click', () => {
1957
+ const evtKey = btn.dataset.evtkey;
1958
+ if (asset?.events?.[nodeKey]) {
1959
+ delete asset.events[nodeKey][evtKey];
1960
+ if (Object.keys(asset.events[nodeKey]).length === 0) delete asset.events[nodeKey];
1961
+ if (onChange) onChange(asset);
1962
+ this._show3DEventsInDock(node, asset, nodeKey, onChange);
1963
+ }
1964
+ });
1965
+ });
1966
+
1967
+ // Wire add button
1968
+ d3.querySelector('.evt-add-btn')?.addEventListener('click', () => {
1969
+ const sel = d3.querySelector('.evt-preset-sel');
1970
+ let evtKey = sel?.value;
1971
+ if (!evtKey) return;
1972
+ if (evtKey === '__custom__') {
1973
+ evtKey = prompt('Custom event name:', 'signalChange:mywebui.0.signal.path');
1974
+ if (!evtKey) return;
1975
+ }
1976
+ if (!asset.events) asset.events = {};
1977
+ if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
1978
+ if (!asset.events[nodeKey][evtKey]) asset.events[nodeKey][evtKey] = '// event: ' + evtKey + '\n';
1979
+ if (onChange) onChange(asset);
1980
+ this._show3DEventsInDock(node, asset, nodeKey, onChange);
1981
+ });
1982
+
1983
+ // Activate the events dock panel
1984
+ this.activateDockById('eventsDock');
1985
+ }
1986
+
1987
+ _restore3DEventsDock() {
1988
+ const dock = this._getDomElement('eventsDock');
1989
+ if (!dock) return;
1990
+ const d3 = dock.querySelector('#_3dEventsOverlay');
1991
+ if (d3) d3.style.display = 'none';
1992
+ const nativeEvt = this._getDomElement('eventsList');
1993
+ if (nativeEvt) nativeEvt.style.display = '';
1858
1994
  }
1859
1995
 
1860
1996
  _showAddDriveDialog(obj, onChange) {
@@ -1978,6 +2114,18 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1978
2114
  editor.id = id;
1979
2115
  editor.title = '3D: ' + name;
1980
2116
  editor.setAttribute('scene-name', name);
2117
+ editor.setAttribute('scene-type', '3dscreen');
2118
+ this.openDock(editor);
2119
+ }
2120
+ }
2121
+ async open3DControlEditor(name) {
2122
+ let id = '3dcontrol_' + name;
2123
+ if (!this.isDockOpenAndActivate(id)) {
2124
+ let editor = new IobrokerWebui3DScreenEditor();
2125
+ editor.id = id;
2126
+ editor.title = '3D Ctrl: ' + name;
2127
+ editor.setAttribute('scene-name', name);
2128
+ editor.setAttribute('scene-type', '3dcontrol');
1981
2129
  this.openDock(editor);
1982
2130
  }
1983
2131
  }
@@ -5,6 +5,17 @@ import { exportData, openFileDialog } from "../helper/Helper.js";
5
5
  import { getCustomControlName, webuiCustomControlPrefix } from "../runtime/CustomControls.js";
6
6
  import { defaultOptions, defaultStyle } from "@gokturk413/web-component-designer-widgets-wunderbaum";
7
7
  import { Wunderbaum } from 'wunderbaum';
8
+ const default3DScript = `// 3D Scene Script
9
+ // Context: THREE, scene, camera, renderer, controls
10
+ // assets (Map<name, Object3D>) mixers (Map<assetId, {mixer, clips, actions}>)
11
+ // driveEngine setState(signal, val) getState(signal)
12
+ // subscribe(signal, cb) → returns unsubscribe fn
13
+ // onFrame(fn) onSelect(fn) log(...)
14
+
15
+ onFrame((dt, ts) => {
16
+ // Runs every animation frame. dt = delta seconds.
17
+ });
18
+ `;
8
19
  //@ts-ignore
9
20
  import wunderbaumStyle from 'wunderbaum/dist/wunderbaum.css' with { type: 'css' };
10
21
  import { defaultNewStyle, defaultNewControlScript } from "./IobrokerWebuiScreenEditor.js";
@@ -102,6 +113,7 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
102
113
  const result = await Promise.allSettled([
103
114
  this._createFolderNode('screen'),
104
115
  this._createFolderNode('3dscreen'),
116
+ this._createFolderNode('3dcontrol'),
105
117
  this._createControlsNode(),
106
118
  this._createGlobalNode(),
107
119
  this._createNpmsNode(),
@@ -119,62 +131,32 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
119
131
  try {
120
132
  let screenName = prompt("New " + type + " Name:");
121
133
  if (screenName) {
122
- if (type === '3dscreen') {
123
- // Create new 3D screen with default scene
124
- const defaultScene = {
134
+ if (type === '3dscreen' || type === '3dcontrol') {
135
+ const makeDefaultScene = (nm) => ({
125
136
  id: 'scene_' + Date.now().toString(36),
126
- name: screenName,
137
+ name: nm,
127
138
  version: '1.0',
128
139
  createdAt: new Date().toISOString(),
129
140
  modifiedAt: new Date().toISOString(),
130
141
  assets: [],
142
+ script: default3DScript,
143
+ style: '',
131
144
  lights: [
132
- {
133
- id: 'ambient_light',
134
- name: 'Ambient Light',
135
- type: 'ambient',
136
- color: '#ffffff',
137
- intensity: 0.6
138
- },
139
- {
140
- id: 'directional_light',
141
- name: 'Directional Light',
142
- type: 'directional',
143
- color: '#ffffff',
144
- intensity: 0.8,
145
- position: { x: 10, y: 10, z: 10 },
146
- castShadow: true
147
- }
145
+ { id: 'ambient_light', name: 'Ambient Light', type: 'ambient', color: '#ffffff', intensity: 0.6 },
146
+ { id: 'directional_light', name: 'Directional Light', type: 'directional', color: '#ffffff', intensity: 0.8, position: { x: 10, y: 10, z: 10 }, castShadow: true }
148
147
  ],
149
- camera: {
150
- position: { x: 10, y: 10, z: 10 },
151
- target: { x: 0, y: 0, z: 0 },
152
- fov: 75
153
- },
154
- grid: {
155
- visible: true,
156
- size: 20,
157
- divisions: 20,
158
- colorCenterLine: '#888888',
159
- colorGrid: '#444444'
160
- },
161
- axes: {
162
- visible: true,
163
- size: 5
164
- },
165
- settings: {
166
- backgroundColor: '#333333',
167
- enableControls: true,
168
- enableRaycasting: true,
169
- shadowsEnabled: true,
170
- antialiasing: true
171
- },
172
- edits: {
173
- ops: []
174
- }
175
- };
176
- await iobrokerHandler.saveObject('3dscreen', (dir ?? '') + '/' + screenName, defaultScene);
177
- window.appShell.open3DScreenEditor((dir ?? '') + '/' + screenName);
148
+ camera: { position: { x: 10, y: 10, z: 10 }, target: { x: 0, y: 0, z: 0 }, fov: 75 },
149
+ grid: { visible: true, size: 20, divisions: 20, colorCenterLine: '#888888', colorGrid: '#444444' },
150
+ axes: { visible: true, size: 5 },
151
+ settings: { backgroundColor: '#333333', enableControls: true, enableRaycasting: true, shadowsEnabled: true, antialiasing: true },
152
+ edits: { ops: [] }
153
+ });
154
+ const fullName = (dir ?? '') + '/' + screenName;
155
+ await iobrokerHandler.saveObject(type, fullName, makeDefaultScene(fullName));
156
+ if (type === '3dcontrol')
157
+ window.appShell.open3DControlEditor(fullName);
158
+ else
159
+ window.appShell.open3DScreenEditor(fullName);
178
160
  } else {
179
161
  const defaultScript = type === 'control' ? defaultNewControlScript : null;
180
162
  window.appShell.openScreenEditor((dir ?? '') + '/' + screenName, type, '', defaultNewStyle, defaultScript, {});
@@ -263,6 +245,9 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
263
245
  case '3dscreen':
264
246
  name = "3D Screens";
265
247
  break;
248
+ case '3dcontrol':
249
+ name = "3D Custom Controls";
250
+ break;
266
251
  //case '':
267
252
  // name='Additional Files';
268
253
  // break;
@@ -368,6 +353,9 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
368
353
  else if (type == '3dscreen') {
369
354
  window.appShell.open3DScreenEditor(nm);
370
355
  }
356
+ else if (type == '3dcontrol') {
357
+ window.appShell.open3DControlEditor(nm);
358
+ }
371
359
  });
372
360
  },
373
361
  data: { type, name: (dir ?? '') + '/' + x }
package/www/runtime.html CHANGED
@@ -49,6 +49,7 @@
49
49
 
50
50
  <body>
51
51
  <iobroker-webui-screen-viewer id="viewer"></iobroker-webui-screen-viewer>
52
+ <div id="viewer3dContainer" style="display:none;width:100%;height:100%;position:fixed;top:0;left:0;"></div>
52
53
  <div id="overlayLayer"></div>
53
54
  </body>
54
55
 
@@ -68,16 +69,35 @@
68
69
  async function checkHash() {
69
70
  const viewer = document.getElementById('viewer');
70
71
  const par = new URLSearchParams(location.hash.substring(1));
71
- let screen = (par).get('screenName');
72
+ const threeDScreen = par.get('3dscreen');
73
+
74
+ if (threeDScreen) {
75
+ // 3D screen runtime mode
76
+ viewer.style.display = 'none';
77
+ const container = document.getElementById('viewer3dContainer');
78
+ container.style.display = 'block';
79
+ container.innerHTML = '';
80
+ await importShim('./dist/frontend/config/IobrokerWebui3DScreenViewer.js');
81
+ await customElements.whenDefined('iobroker-webui-3dscreen-viewer');
82
+ const v3d = document.createElement('iobroker-webui-3dscreen-viewer');
83
+ v3d.setAttribute('scene-name', threeDScreen);
84
+ v3d.style.cssText = 'width:100%;height:100%;display:block;';
85
+ container.appendChild(v3d);
86
+ let imp = await importShim(iobrokerWebuiRootUrl + "dist/frontend/common/IobrokerHandler.js");
87
+ imp.iobrokerHandler.sendCommand("uiChangedView", threeDScreen);
88
+ return;
89
+ }
90
+
91
+ let screen = par.get('screenName');
72
92
  if (screen) {
73
93
  await customElements.whenDefined(viewer.localName);
74
94
  } else {
75
95
  screen = 'start';
76
96
  }
77
97
  await viewer.setScreenNameAndLoad(screen);
78
- let subScreen = (par).get('subScreen');
98
+ let subScreen = par.get('subScreen');
79
99
  if (subScreen) {
80
- const targetSelector = (par).get('targetSelector') ?? 'iobroker-webui-screen-viewer';
100
+ const targetSelector = par.get('targetSelector') ?? 'iobroker-webui-screen-viewer';
81
101
  const sv = viewer._getDomElements(targetSelector)[0];
82
102
  sv.removeAttribute('screen-name');
83
103
  sv.screenName = subScreen;