iobroker.mywebui 1.42.16 → 1.42.18
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
package/package.json
CHANGED
|
@@ -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
|
|
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">💾 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
|
|
28
|
-
<div id="leftPanel" style="width:
|
|
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
|
|
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;">+ 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;"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
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;">▶ 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: // scene, camera, THREE, renderer, controls // assets → Map(name → Object3D) // mixers → Map(assetId → {mixer, clips, actions}) // driveEngine, subscribe(signal, cb), setState(signal, val) // onFrame(fn) → called every animation frame(dt, timestamp) // onSelect(fn) → called when node selected(node, assetData) // Example: // const robot = assets.get('Robot'); // onFrame(dt => { robot.rotation.y += dt; });"></textarea>
|
|
74
|
+
<textarea id="styleEditor" spellcheck="false" placeholder="/* CSS for overlay elements */ /* .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:
|
|
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.
|
|
187
|
-
|
|
188
|
-
const { OrbitControls } = await
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
};
|
|
@@ -263,6 +364,7 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
263
364
|
this._stopAnimation = () => { animating = false; };
|
|
264
365
|
|
|
265
366
|
this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
|
|
367
|
+
this._setupHoverDetection();
|
|
266
368
|
const resizeObs = new ResizeObserver(() => this.onViewportResize());
|
|
267
369
|
resizeObs.observe(viewport);
|
|
268
370
|
}
|
|
@@ -280,6 +382,9 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
280
382
|
try {
|
|
281
383
|
this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
|
|
282
384
|
if (!this.sceneData) { console.warn('Scene not found:', sceneName); return; }
|
|
385
|
+
// Ensure script/style fields exist
|
|
386
|
+
if (!this.sceneData.script) this.sceneData.script = '';
|
|
387
|
+
if (!this.sceneData.style) this.sceneData.style = '';
|
|
283
388
|
console.log('Scene loaded:', sceneName);
|
|
284
389
|
this._buildSceneView();
|
|
285
390
|
} catch (err) {
|
|
@@ -343,6 +448,11 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
343
448
|
this._threeObjects.push(model);
|
|
344
449
|
if (this.driveEngine && Array.isArray(this.sceneData?.assets)) {
|
|
345
450
|
this.driveEngine.createFromAssets(this.sceneData.assets, this._threeObjects);
|
|
451
|
+
// Wrap drives for AtMax/AtMin events
|
|
452
|
+
if (asset.drives?.length) {
|
|
453
|
+
const drives = asset.drives.map((_, i) => this.driveEngine.getDrive(asset.id, i));
|
|
454
|
+
this._wrapDriveEvents(asset, drives);
|
|
455
|
+
}
|
|
346
456
|
}
|
|
347
457
|
// AnimationMixer for built-in GLB animations
|
|
348
458
|
if (gltf.animations?.length > 0) {
|
|
@@ -353,6 +463,12 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
353
463
|
}
|
|
354
464
|
this._mixers.set(asset.id, { mixer, clips: gltf.animations, actions });
|
|
355
465
|
asset.availableClips = gltf.animations.map(c => c.name);
|
|
466
|
+
// animationEnd event
|
|
467
|
+
mixer.addEventListener('finished', (e) => {
|
|
468
|
+
const clipName = e.action.getClip().name;
|
|
469
|
+
this._executeNodeEvent(model, asset, `animationEnd:${clipName}`);
|
|
470
|
+
this._executeNodeEvent(model, asset, 'animationEnd');
|
|
471
|
+
});
|
|
356
472
|
}
|
|
357
473
|
// Extract named node list for scene tree
|
|
358
474
|
asset._nodeList = this._extractNamedNodes(model);
|
|
@@ -408,6 +524,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
408
524
|
|
|
409
525
|
if (hitNode && hitAssetRoot) {
|
|
410
526
|
if (hitNode.userData.interaction) this._handleInteraction(hitNode);
|
|
527
|
+
// Fire node event: check by name AND by uuid
|
|
528
|
+
this._executeNodeEvent(hitNode, hitAssetRoot.userData.assetData, 'click');
|
|
411
529
|
this.selectNode(hitNode, hitAssetRoot);
|
|
412
530
|
} else {
|
|
413
531
|
this.deselectAll();
|
|
@@ -430,6 +548,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
430
548
|
this._expandedAssets.add(assetRoot.userData.assetData?.id);
|
|
431
549
|
this.refreshTree();
|
|
432
550
|
this._showNodeProperties(node, assetRoot);
|
|
551
|
+
// User script select callbacks
|
|
552
|
+
for (const fn of this._scriptSelectCallbacks) {
|
|
553
|
+
try { fn(node, assetRoot.userData.assetData); } catch (_) {}
|
|
554
|
+
}
|
|
433
555
|
}
|
|
434
556
|
|
|
435
557
|
selectObject(obj) {
|
|
@@ -529,6 +651,131 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
529
651
|
}
|
|
530
652
|
}
|
|
531
653
|
|
|
654
|
+
// ── Event execution ───────────────────────────────────
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Execute a node event handler stored in asset.events[nodeName][eventKey].
|
|
658
|
+
* eventKey: 'click' | 'mouseenter' | 'mouseleave' | 'animationEnd' | 'animationEnd:ClipName' |
|
|
659
|
+
* 'driveAtMax:N' | 'driveAtMin:N' | 'signalChange:path'
|
|
660
|
+
* Node name '__root__' = the asset root object.
|
|
661
|
+
*/
|
|
662
|
+
_executeNodeEvent(node, asset, eventKey) {
|
|
663
|
+
if (!asset?.events) return;
|
|
664
|
+
const nodeKey = (node?.userData?.assetData === asset || !node?.name) ? '__root__' : node.name;
|
|
665
|
+
const script = asset.events[nodeKey]?.[eventKey];
|
|
666
|
+
if (!script?.trim()) return;
|
|
667
|
+
|
|
668
|
+
const ctx = {
|
|
669
|
+
node,
|
|
670
|
+
assetData: asset,
|
|
671
|
+
THREE: this.THREE,
|
|
672
|
+
scene: this.scene,
|
|
673
|
+
camera: this.camera,
|
|
674
|
+
renderer: this.renderer,
|
|
675
|
+
controls: this.controls,
|
|
676
|
+
assets: this._buildAssetsMap(),
|
|
677
|
+
mixers: this._mixers,
|
|
678
|
+
driveEngine: this.driveEngine,
|
|
679
|
+
setState(signal, val) { iobrokerHandler.setState(signal, val); },
|
|
680
|
+
getState: (s) => this.driveEngine.bus.get(s),
|
|
681
|
+
subscribe: (s, cb) => { const u = this.driveEngine.bus.subscribe(s, cb); this._scriptUnsubs.push(u); return u; },
|
|
682
|
+
log: (...a) => console.log('[3D Event]', nodeKey, eventKey, ...a),
|
|
683
|
+
};
|
|
684
|
+
try {
|
|
685
|
+
new Function(...Object.keys(ctx), script)(...Object.values(ctx));
|
|
686
|
+
} catch (err) {
|
|
687
|
+
console.error(`[3D Event ${nodeKey}:${eventKey}]`, err.message);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
_buildAssetsMap() {
|
|
692
|
+
const m = new Map();
|
|
693
|
+
for (const obj of this.scene.children) {
|
|
694
|
+
if (obj.userData.assetData) {
|
|
695
|
+
m.set(obj.userData.assetData.name, obj);
|
|
696
|
+
m.set(obj.userData.assetData.id, obj);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return m;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ── Hover detection ───────────────────────────────────
|
|
703
|
+
|
|
704
|
+
_setupHoverDetection() {
|
|
705
|
+
let hoverNode = null;
|
|
706
|
+
let hoverAsset = null;
|
|
707
|
+
let throttle = null;
|
|
708
|
+
|
|
709
|
+
this.renderer.domElement.addEventListener('mousemove', (e) => {
|
|
710
|
+
if (throttle) return;
|
|
711
|
+
throttle = setTimeout(() => { throttle = null; }, 50); // 20 fps check
|
|
712
|
+
|
|
713
|
+
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
714
|
+
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
715
|
+
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
716
|
+
this.raycaster.setFromCamera(this.mouse, this.camera);
|
|
717
|
+
const hits = this.raycaster.intersectObjects(this.scene.children, true);
|
|
718
|
+
|
|
719
|
+
let newNode = null, newRoot = null;
|
|
720
|
+
for (const hit of hits) {
|
|
721
|
+
const o = hit.object;
|
|
722
|
+
if (o.userData.isGrid || o.userData.isAxes || o.userData.isSelectionHelper) continue;
|
|
723
|
+
let p = o;
|
|
724
|
+
while (p && p !== this.scene) {
|
|
725
|
+
if (p.userData.assetData) { newRoot = p; break; }
|
|
726
|
+
p = p.parent;
|
|
727
|
+
}
|
|
728
|
+
if (newRoot) { newNode = o; break; }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (newNode !== hoverNode) {
|
|
732
|
+
if (hoverNode && hoverAsset) {
|
|
733
|
+
this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
|
|
734
|
+
this.renderer.domElement.style.cursor = 'default';
|
|
735
|
+
}
|
|
736
|
+
if (newNode && newRoot) {
|
|
737
|
+
const asset = newRoot.userData.assetData;
|
|
738
|
+
this._executeNodeEvent(newNode, asset, 'mouseenter');
|
|
739
|
+
// Change cursor if node has any event or interaction
|
|
740
|
+
const hasEvent = asset?.events || newNode.userData.interaction;
|
|
741
|
+
if (hasEvent) this.renderer.domElement.style.cursor = 'pointer';
|
|
742
|
+
}
|
|
743
|
+
hoverNode = newNode;
|
|
744
|
+
hoverAsset = newRoot?.userData.assetData ?? null;
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
this.renderer.domElement.addEventListener('mouseleave', () => {
|
|
749
|
+
if (hoverNode && hoverAsset) {
|
|
750
|
+
this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
|
|
751
|
+
}
|
|
752
|
+
hoverNode = null; hoverAsset = null;
|
|
753
|
+
this.renderer.domElement.style.cursor = 'default';
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── Drive events (AtMax / AtMin) ──────────────────────
|
|
758
|
+
// Called from DriveEngine after createFromAssets - wraps onAfterUpdate
|
|
759
|
+
_wrapDriveEvents(asset, drives) {
|
|
760
|
+
for (let i = 0; i < drives.length; i++) {
|
|
761
|
+
const drive = drives[i];
|
|
762
|
+
if (!drive) continue;
|
|
763
|
+
const cfg = asset.drives?.[i];
|
|
764
|
+
const origCb = drive.onAfterUpdate;
|
|
765
|
+
drive.onAfterUpdate = (d) => {
|
|
766
|
+
if (origCb) origCb(d);
|
|
767
|
+
const root = this._threeObjects.find(o => o.userData.assetId === asset.id);
|
|
768
|
+
if (!root) return;
|
|
769
|
+
const atMax = cfg?.maxPos !== undefined ? Math.abs(d.currentPosition - cfg.maxPos) < 0.1 : false;
|
|
770
|
+
const atMin = cfg?.minPos !== undefined ? Math.abs(d.currentPosition - cfg.minPos) < 0.1 : false;
|
|
771
|
+
if (atMax && !d._evtWasAtMax) this._executeNodeEvent(root, asset, `driveAtMax:${i}`);
|
|
772
|
+
if (atMin && !d._evtWasAtMin) this._executeNodeEvent(root, asset, `driveAtMin:${i}`);
|
|
773
|
+
d._evtWasAtMax = atMax;
|
|
774
|
+
d._evtWasAtMin = atMin;
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
532
779
|
_extractNamedNodes(obj, depth = 0, result = []) {
|
|
533
780
|
if (depth > 7) return result;
|
|
534
781
|
if (depth > 0 && obj.name) {
|
|
@@ -546,11 +793,152 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
546
793
|
return result;
|
|
547
794
|
}
|
|
548
795
|
|
|
796
|
+
// ── Bottom Script/Style panel ─────────────────────────
|
|
797
|
+
|
|
798
|
+
_setupBottomPanel() {
|
|
799
|
+
const root = this._getDomElement('root');
|
|
800
|
+
const panel = this._getDomElement('bottomPanel');
|
|
801
|
+
const handle = this._getDomElement('bottomHandle');
|
|
802
|
+
const scriptTA = this._getDomElement('scriptEditor');
|
|
803
|
+
const styleTA = this._getDomElement('styleEditor');
|
|
804
|
+
|
|
805
|
+
// Populate from sceneData
|
|
806
|
+
if (scriptTA && this.sceneData?.script) scriptTA.value = this.sceneData.script;
|
|
807
|
+
if (styleTA && this.sceneData?.style) styleTA.value = this.sceneData.style;
|
|
808
|
+
|
|
809
|
+
// Set initial active editor
|
|
810
|
+
if (scriptTA) scriptTA.classList.add('active');
|
|
811
|
+
|
|
812
|
+
// Tab switching
|
|
813
|
+
['btTabScript','btTabStyle'].forEach(id => {
|
|
814
|
+
const btn = this._getDomElement(id);
|
|
815
|
+
if (!btn) return;
|
|
816
|
+
btn.addEventListener('click', () => {
|
|
817
|
+
this.shadowRoot.querySelectorAll('.bt-tab').forEach(b => b.classList.remove('active'));
|
|
818
|
+
btn.classList.add('active');
|
|
819
|
+
const tab = btn.dataset.btab;
|
|
820
|
+
if (scriptTA) scriptTA.classList.toggle('active', tab === 'script');
|
|
821
|
+
if (styleTA) styleTA.classList.toggle('active', tab === 'style');
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// Run button
|
|
826
|
+
this._getDomElement('runScriptBtn')?.addEventListener('click', () => this._runUserScript());
|
|
827
|
+
|
|
828
|
+
// Ctrl+Enter to run while editing
|
|
829
|
+
scriptTA?.addEventListener('keydown', (e) => {
|
|
830
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); this._runUserScript(); }
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// Auto-save script/style to sceneData on change
|
|
834
|
+
scriptTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.script = scriptTA.value; });
|
|
835
|
+
styleTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.style = styleTA.value; this._applyUserStyle(); });
|
|
836
|
+
|
|
837
|
+
// Draggable resize handle
|
|
838
|
+
let dragStart = null;
|
|
839
|
+
let startH = 0;
|
|
840
|
+
handle?.addEventListener('mousedown', (e) => {
|
|
841
|
+
dragStart = e.clientY;
|
|
842
|
+
startH = panel ? panel.offsetHeight : 0;
|
|
843
|
+
// If panel collapsed, open it
|
|
844
|
+
if (startH < 20) startH = 0;
|
|
845
|
+
document.addEventListener('mousemove', onDrag);
|
|
846
|
+
document.addEventListener('mouseup', onUp);
|
|
847
|
+
});
|
|
848
|
+
const onDrag = (e) => {
|
|
849
|
+
if (dragStart === null) return;
|
|
850
|
+
const delta = dragStart - e.clientY;
|
|
851
|
+
const h = Math.max(0, Math.min(startH + delta, 500));
|
|
852
|
+
if (panel) panel.style.height = h + 'px';
|
|
853
|
+
};
|
|
854
|
+
const onUp = () => { dragStart = null; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', onUp); };
|
|
855
|
+
|
|
856
|
+
// Click handle to toggle
|
|
857
|
+
handle?.addEventListener('click', (e) => {
|
|
858
|
+
if (!panel) return;
|
|
859
|
+
const h = panel.offsetHeight;
|
|
860
|
+
panel.style.height = h < 20 ? '200px' : '0';
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
_applyUserStyle() {
|
|
865
|
+
const styleTA = this._getDomElement('styleEditor');
|
|
866
|
+
const css = styleTA?.value || this.sceneData?.style || '';
|
|
867
|
+
let styleEl = this.shadowRoot.getElementById('userStyle3d');
|
|
868
|
+
if (!styleEl) {
|
|
869
|
+
styleEl = document.createElement('style');
|
|
870
|
+
styleEl.id = 'userStyle3d';
|
|
871
|
+
this.shadowRoot.appendChild(styleEl);
|
|
872
|
+
}
|
|
873
|
+
styleEl.textContent = css;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
_runUserScript() {
|
|
877
|
+
// Clear previous script state
|
|
878
|
+
for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
|
|
879
|
+
this._scriptUnsubs = [];
|
|
880
|
+
this._scriptFrameCallbacks = [];
|
|
881
|
+
this._scriptSelectCallbacks = [];
|
|
882
|
+
|
|
883
|
+
const scriptTA = this._getDomElement('scriptEditor');
|
|
884
|
+
const errBtn = this._getDomElement('scriptErrBtn');
|
|
885
|
+
const script = scriptTA?.value || this.sceneData?.script || '';
|
|
886
|
+
if (!script.trim()) return;
|
|
887
|
+
|
|
888
|
+
// Build assets map: name/id → Three.js Object3D
|
|
889
|
+
const assets = new Map();
|
|
890
|
+
for (const obj of this.scene.children) {
|
|
891
|
+
if (obj.userData.assetData) {
|
|
892
|
+
assets.set(obj.userData.assetData.name, obj);
|
|
893
|
+
assets.set(obj.userData.assetData.id, obj);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const self = this;
|
|
898
|
+
const ctx = {
|
|
899
|
+
THREE: this.THREE,
|
|
900
|
+
scene: this.scene,
|
|
901
|
+
camera: this.camera,
|
|
902
|
+
renderer: this.renderer,
|
|
903
|
+
controls: this.controls,
|
|
904
|
+
assets,
|
|
905
|
+
mixers: this._mixers,
|
|
906
|
+
driveEngine: this.driveEngine,
|
|
907
|
+
subscribe(signal, cb) {
|
|
908
|
+
const unsub = self.driveEngine.bus.subscribe(signal, cb);
|
|
909
|
+
self._scriptUnsubs.push(unsub);
|
|
910
|
+
return unsub;
|
|
911
|
+
},
|
|
912
|
+
setState(signal, val) { iobrokerHandler.setState(signal, val); },
|
|
913
|
+
getState(signal) { return self.driveEngine.bus.get(signal); },
|
|
914
|
+
onFrame(fn) { self._scriptFrameCallbacks.push(fn); },
|
|
915
|
+
onSelect(fn) { self._scriptSelectCallbacks.push(fn); },
|
|
916
|
+
log(...args) { console.log('[3D Script]', ...args); },
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
if (errBtn) errBtn.style.display = 'none';
|
|
920
|
+
try {
|
|
921
|
+
const fn = new Function(...Object.keys(ctx), script);
|
|
922
|
+
fn(...Object.values(ctx));
|
|
923
|
+
} catch (err) {
|
|
924
|
+
console.error('[3D Script] Error:', err);
|
|
925
|
+
if (errBtn) { errBtn.style.display = ''; errBtn.textContent = '⚠ ' + err.message; }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
549
929
|
// ── Scene tree (left panel) ───────────────────────────
|
|
550
930
|
|
|
551
931
|
refreshTree() {
|
|
552
932
|
const el = this._getDomElement('treeContent');
|
|
553
|
-
if (!el
|
|
933
|
+
if (!el) return;
|
|
934
|
+
|
|
935
|
+
// Library tab - show reusable 3D components
|
|
936
|
+
if (this._activeTab === 'library') {
|
|
937
|
+
this._renderLibraryTab(el);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (!this.sceneData) return;
|
|
554
942
|
const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
|
|
555
943
|
el.innerHTML = '';
|
|
556
944
|
|
|
@@ -714,42 +1102,80 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
714
1102
|
}
|
|
715
1103
|
}
|
|
716
1104
|
|
|
717
|
-
// ── Library (right panel)
|
|
1105
|
+
// ── Library (right panel - kept for backward compat) ───
|
|
718
1106
|
|
|
719
1107
|
refreshLibrary() {
|
|
720
1108
|
const el = this._getDomElement('libraryGrid');
|
|
721
1109
|
if (!el || !this.sceneData) return;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1110
|
+
// Library now lives primarily in left panel tab
|
|
1111
|
+
// Right panel libraryGrid kept but hidden
|
|
1112
|
+
}
|
|
725
1113
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1114
|
+
// ── Library tab in left panel ─────────────────────────
|
|
1115
|
+
|
|
1116
|
+
_renderLibraryTab(el) {
|
|
1117
|
+
el.innerHTML = '';
|
|
1118
|
+
const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
|
|
1119
|
+
const assets = Array.isArray(this.sceneData?.assets) ? this.sceneData.assets : [];
|
|
1120
|
+
const filtered = assets.filter(a => !search || a.name.toLowerCase().includes(search));
|
|
1121
|
+
|
|
1122
|
+
// Show/hide add button
|
|
1123
|
+
const addRow = this._getDomElement('libAddRow');
|
|
1124
|
+
if (addRow) addRow.style.display = '';
|
|
1125
|
+
|
|
1126
|
+
if (filtered.length === 0) {
|
|
1127
|
+
el.innerHTML = `<div style="color:#555;padding:16px 8px;font-size:11px;text-align:center;line-height:1.8;">
|
|
1128
|
+
No 3D components yet.<br>
|
|
1129
|
+
<span style="color:#9cdcfe;">Import GLB</span> to create<br>a reusable component.
|
|
729
1130
|
</div>`;
|
|
730
1131
|
return;
|
|
731
1132
|
}
|
|
732
1133
|
|
|
733
|
-
|
|
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';
|
|
1134
|
+
for (const asset of filtered) {
|
|
741
1135
|
const shortName = asset.name.replace(/\.(glb|gltf)$/i, '');
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1136
|
+
const driveCnt = asset.drives?.length ?? 0;
|
|
1137
|
+
const clipCnt = asset.availableClips?.length ?? 0;
|
|
1138
|
+
const interCnt = Object.keys(asset.interactions ?? {}).length;
|
|
1139
|
+
const meta = [
|
|
1140
|
+
driveCnt ? `${driveCnt}D` : '',
|
|
1141
|
+
clipCnt ? `${clipCnt}A` : '',
|
|
1142
|
+
interCnt ? `${interCnt}I` : '',
|
|
1143
|
+
].filter(Boolean).join(' · ') || 'GLB';
|
|
1144
|
+
|
|
1145
|
+
const item = document.createElement('div');
|
|
1146
|
+
item.className = 'lib-item';
|
|
1147
|
+
item.innerHTML = `
|
|
1148
|
+
<span class="lib-icon">📦</span>
|
|
1149
|
+
<div class="lib-info">
|
|
1150
|
+
<div class="lib-name" title="${asset.name}">${shortName}</div>
|
|
1151
|
+
<div class="lib-meta">${meta}</div>
|
|
1152
|
+
</div>
|
|
1153
|
+
<span class="lib-add" title="Add instance to scene">+</span>
|
|
746
1154
|
`;
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1155
|
+
item.querySelector('.lib-add')?.addEventListener('click', (e) => {
|
|
1156
|
+
e.stopPropagation();
|
|
1157
|
+
this._addAssetInstance(asset);
|
|
1158
|
+
});
|
|
1159
|
+
item.addEventListener('click', () => this._selectAsset(asset));
|
|
1160
|
+
el.appendChild(item);
|
|
750
1161
|
}
|
|
751
1162
|
}
|
|
752
1163
|
|
|
1164
|
+
_addAssetInstance(templateAsset) {
|
|
1165
|
+
// Duplicate the asset as a new instance in the scene
|
|
1166
|
+
const newId = 'asset_' + Date.now().toString(36);
|
|
1167
|
+
const instance = JSON.parse(JSON.stringify(templateAsset));
|
|
1168
|
+
instance.id = newId;
|
|
1169
|
+
instance.name = templateAsset.name + '_' + newId.slice(-4);
|
|
1170
|
+
instance.position = { x: Math.random() * 2 - 1, y: 0, z: Math.random() * 2 - 1 };
|
|
1171
|
+
if (!this.sceneData.assets) this.sceneData.assets = [];
|
|
1172
|
+
this.sceneData.assets.push(instance);
|
|
1173
|
+
this.loadAsset(instance);
|
|
1174
|
+
this.refreshTree();
|
|
1175
|
+
this._setStatus('Instance added: ' + instance.name);
|
|
1176
|
+
setTimeout(() => this._setStatus(''), 2000);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
753
1179
|
// ── File input ────────────────────────────────────────
|
|
754
1180
|
|
|
755
1181
|
onFileSelected(event) {
|
|
@@ -839,19 +1265,25 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
839
1265
|
this._getDomElement('gridToggle').addEventListener('click', () => this.toggleGrid());
|
|
840
1266
|
this._getDomElement('axesToggle').addEventListener('click', () => this.toggleAxes());
|
|
841
1267
|
|
|
842
|
-
// Tree tabs
|
|
843
|
-
['tabAll','tabAssets','tabLights','tabSignals'].forEach(id => {
|
|
844
|
-
this._getDomElement(id)
|
|
1268
|
+
// Tree tabs (including Library)
|
|
1269
|
+
['tabAll','tabAssets','tabLights','tabSignals','tabLibrary'].forEach(id => {
|
|
1270
|
+
const el = this._getDomElement(id);
|
|
1271
|
+
if (!el) return;
|
|
1272
|
+
el.addEventListener('click', (e) => {
|
|
845
1273
|
this._activeTab = e.target.dataset.tab;
|
|
846
1274
|
this.shadowRoot.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
|
847
1275
|
e.target.classList.add('active');
|
|
1276
|
+
const addRow = this._getDomElement('libAddRow');
|
|
1277
|
+
if (addRow) addRow.style.display = this._activeTab === 'library' ? '' : 'none';
|
|
848
1278
|
this.refreshTree();
|
|
849
1279
|
});
|
|
850
1280
|
});
|
|
851
1281
|
|
|
1282
|
+
// Library add button
|
|
1283
|
+
this._getDomElement('addLibraryBtn')?.addEventListener('click', () => this._getDomElement('fileInput').click());
|
|
1284
|
+
|
|
852
1285
|
// Search
|
|
853
|
-
this._getDomElement('treeSearch')
|
|
854
|
-
this._getDomElement('libSearch').addEventListener('input', () => this.refreshLibrary());
|
|
1286
|
+
this._getDomElement('treeSearch')?.addEventListener('input', () => this.refreshTree());
|
|
855
1287
|
}
|
|
856
1288
|
|
|
857
1289
|
disconnectedCallback() {
|
|
@@ -859,6 +1291,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
859
1291
|
if (this.driveEngine) this.driveEngine.destroy();
|
|
860
1292
|
for (const { mixer } of this._mixers.values()) mixer.stopAllAction();
|
|
861
1293
|
this._mixers.clear();
|
|
1294
|
+
for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
|
|
1295
|
+
this._scriptUnsubs = [];
|
|
862
1296
|
if (this.renderer) {
|
|
863
1297
|
this.renderer.dispose();
|
|
864
1298
|
this.renderer.domElement?.remove();
|
|
@@ -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;">
|
|
@@ -1721,6 +1721,50 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1721
1721
|
</div>`
|
|
1722
1722
|
: `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No interaction</div>`;
|
|
1723
1723
|
|
|
1724
|
+
// ── Events ──
|
|
1725
|
+
const nodeEvents = asset?.events?.[nodeKey] ?? {};
|
|
1726
|
+
const nodeEventEntries = Object.entries(nodeEvents);
|
|
1727
|
+
// Build list of available preset events for "Add" dropdown
|
|
1728
|
+
const presetEvents = [
|
|
1729
|
+
{ key: 'click', label: '🖱️ click' },
|
|
1730
|
+
{ key: 'mouseenter', label: '↗️ mouseenter' },
|
|
1731
|
+
{ key: 'mouseleave', label: '↙️ mouseleave' },
|
|
1732
|
+
{ key: 'animationEnd', label: '🎬 animationEnd (any clip)' },
|
|
1733
|
+
...( (asset?.availableClips ?? []).map(c => ({ key: `animationEnd:${c}`, label: `🎬 animationEnd:${c}` })) ),
|
|
1734
|
+
...( (asset?.drives ?? []).map((_, i) => ([
|
|
1735
|
+
{ key: `driveAtMax:${i}`, label: `⬆️ driveAtMax:${i}` },
|
|
1736
|
+
{ key: `driveAtMin:${i}`, label: `⬇️ driveAtMin:${i}` },
|
|
1737
|
+
])).flat() ),
|
|
1738
|
+
].filter(p => !nodeEvents[p.key]); // hide already-configured ones
|
|
1739
|
+
|
|
1740
|
+
const presetOptsHtml = presetEvents.map(p => `<option value="${p.key}">${p.label}</option>`).join('');
|
|
1741
|
+
|
|
1742
|
+
const eventsHtml = nodeEventEntries.length > 0
|
|
1743
|
+
? nodeEventEntries.map(([evtKey, script]) => `
|
|
1744
|
+
<div class="evt-row" style="background:#1a1a2e;border:1px solid #2a2a4a;border-radius:3px;padding:5px;margin-bottom:4px;">
|
|
1745
|
+
<div style="display:flex;align-items:center;gap:4px;margin-bottom:3px;">
|
|
1746
|
+
<span style="color:#dcdcaa;font-size:10px;flex:1;font-family:monospace;">${evtKey}</span>
|
|
1747
|
+
<button class="evt-del-btn tb-btn" data-evtkey="${evtKey}" style="padding:0px 5px;font-size:9px;background:#5a1a1a;color:#f44747;border-color:#8a2a2a;">✕</button>
|
|
1748
|
+
</div>
|
|
1749
|
+
<textarea class="evt-script" data-evtkey="${evtKey}" rows="3"
|
|
1750
|
+
style="width:100%;box-sizing:border-box;background:#0d0d1a;border:1px solid #3c3c5c;color:#d4d4d4;font-family:'Consolas',monospace;font-size:10px;padding:4px;resize:vertical;line-height:1.4;"
|
|
1751
|
+
spellcheck="false"
|
|
1752
|
+
placeholder="// context: node, assetData, THREE, scene, camera // setState(signal, val), getState(signal), subscribe(signal, cb) // assets Map, mixers Map, driveEngine"
|
|
1753
|
+
>${this._escHtml(script)}</textarea>
|
|
1754
|
+
</div>`).join('')
|
|
1755
|
+
: `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No events configured</div>`;
|
|
1756
|
+
|
|
1757
|
+
const eventsAddHtml = presetEvents.length > 0
|
|
1758
|
+
? `<div style="display:flex;gap:4px;margin-top:4px;">
|
|
1759
|
+
<select class="evt-preset-sel" style="flex:1;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:2px 4px;border-radius:3px;font-size:10px;">
|
|
1760
|
+
<option value="">— select event type —</option>
|
|
1761
|
+
${presetOptsHtml}
|
|
1762
|
+
<option value="__custom__">Custom event name...</option>
|
|
1763
|
+
</select>
|
|
1764
|
+
<button class="evt-add-btn tb-btn" style="padding:1px 10px;font-size:10px;">+ Add</button>
|
|
1765
|
+
</div>`
|
|
1766
|
+
: `<div style="color:#555;font-size:9px;margin-top:4px;">All event types configured</div>`;
|
|
1767
|
+
|
|
1724
1768
|
panel.innerHTML = `
|
|
1725
1769
|
<style>
|
|
1726
1770
|
.pg-group{margin-bottom:8px;}
|
|
@@ -1771,6 +1815,19 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1771
1815
|
<div class="pg-header">🖱️ Interaction (onClick) <button class="inter-add-btn tb-btn" style="padding:1px 8px;font-size:9px;">+ Set</button></div>
|
|
1772
1816
|
${interHtml}
|
|
1773
1817
|
</div>
|
|
1818
|
+
<div class="pg-group">
|
|
1819
|
+
<div class="pg-header">⚡ Events
|
|
1820
|
+
<span style="color:#555;font-size:9px;font-weight:normal;">node: ${nodeKey}</span>
|
|
1821
|
+
</div>
|
|
1822
|
+
${eventsHtml}
|
|
1823
|
+
${eventsAddHtml}
|
|
1824
|
+
<div style="color:#555;font-size:9px;margin-top:6px;line-height:1.5;">
|
|
1825
|
+
Script context:<br>
|
|
1826
|
+
<code style="color:#888;">node, assetData, THREE, scene</code><br>
|
|
1827
|
+
<code style="color:#888;">setState(sig,val) · getState(sig)</code><br>
|
|
1828
|
+
<code style="color:#888;">assets(Map) · mixers · driveEngine</code>
|
|
1829
|
+
</div>
|
|
1830
|
+
</div>
|
|
1774
1831
|
</div>
|
|
1775
1832
|
`;
|
|
1776
1833
|
|
|
@@ -1855,6 +1912,53 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1855
1912
|
this.show3DNodeProperties(node, asset, mixerInfo, onChange);
|
|
1856
1913
|
}
|
|
1857
1914
|
});
|
|
1915
|
+
|
|
1916
|
+
// ── Events wiring ──
|
|
1917
|
+
// Script textarea auto-save on blur
|
|
1918
|
+
p.querySelectorAll('.evt-script').forEach(ta => {
|
|
1919
|
+
ta.addEventListener('blur', () => {
|
|
1920
|
+
const evtKey = ta.dataset.evtkey;
|
|
1921
|
+
if (!asset.events) asset.events = {};
|
|
1922
|
+
if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
|
|
1923
|
+
asset.events[nodeKey][evtKey] = ta.value;
|
|
1924
|
+
if (onChange) onChange(asset);
|
|
1925
|
+
});
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
// Delete event
|
|
1929
|
+
p.querySelectorAll('.evt-del-btn').forEach(btn => {
|
|
1930
|
+
btn.addEventListener('click', () => {
|
|
1931
|
+
const evtKey = btn.dataset.evtkey;
|
|
1932
|
+
if (asset?.events?.[nodeKey]) {
|
|
1933
|
+
delete asset.events[nodeKey][evtKey];
|
|
1934
|
+
if (Object.keys(asset.events[nodeKey]).length === 0) delete asset.events[nodeKey];
|
|
1935
|
+
if (onChange) onChange(asset);
|
|
1936
|
+
this.show3DNodeProperties(node, asset, mixerInfo, onChange);
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
// Add event from preset dropdown
|
|
1942
|
+
p.querySelector('.evt-add-btn')?.addEventListener('click', () => {
|
|
1943
|
+
const sel = p.querySelector('.evt-preset-sel');
|
|
1944
|
+
let evtKey = sel?.value;
|
|
1945
|
+
if (!evtKey) return;
|
|
1946
|
+
if (evtKey === '__custom__') {
|
|
1947
|
+
evtKey = prompt('Custom event name:', 'signalChange:mywebui.0.signal.path');
|
|
1948
|
+
if (!evtKey) return;
|
|
1949
|
+
}
|
|
1950
|
+
if (!asset.events) asset.events = {};
|
|
1951
|
+
if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
|
|
1952
|
+
if (!asset.events[nodeKey][evtKey]) {
|
|
1953
|
+
asset.events[nodeKey][evtKey] = '// event: ' + evtKey + '\n';
|
|
1954
|
+
}
|
|
1955
|
+
if (onChange) onChange(asset);
|
|
1956
|
+
this.show3DNodeProperties(node, asset, mixerInfo, onChange);
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
_escHtml(str) {
|
|
1961
|
+
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
1858
1962
|
}
|
|
1859
1963
|
|
|
1860
1964
|
_showAddDriveDialog(obj, onChange) {
|