iobroker.mywebui 1.42.0 → 1.42.1
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/package.json +1 -1
- package/www/dist/frontend/common/IobrokerHandler.js +20 -0
- package/www/dist/frontend/config/3d-scene-store.ts +304 -0
- package/www/dist/frontend/config/3d-scene-types.ts +277 -0
- package/www/dist/frontend/config/IobrokerWebui3DScreenEditor.js +480 -0
- package/www/dist/frontend/config/IobrokerWebui3DScreenViewer.js +251 -0
- package/www/dist/frontend/config/IobrokerWebuiSolutionExplorer.js +9 -0
package/package.json
CHANGED
|
@@ -36,6 +36,7 @@ export class IobrokerHandler {
|
|
|
36
36
|
this.clientId = Date.now().toString(16);
|
|
37
37
|
this.#cache.set('screen', new Map());
|
|
38
38
|
this.#cache.set('control', new Map());
|
|
39
|
+
this.#cache.set('3dscreen', new Map());
|
|
39
40
|
}
|
|
40
41
|
getNormalizedSignalName(id, relativeSignalPath, element) {
|
|
41
42
|
return (relativeSignalPath ?? '') + id;
|
|
@@ -205,6 +206,8 @@ export class IobrokerHandler {
|
|
|
205
206
|
return this.getScreen(name);
|
|
206
207
|
else if (type == 'control')
|
|
207
208
|
return this.getCustomControl(name);
|
|
209
|
+
else if (type == '3dscreen')
|
|
210
|
+
return this.get3DScreen(name);
|
|
208
211
|
return null;
|
|
209
212
|
}
|
|
210
213
|
async getScreen(name) {
|
|
@@ -224,6 +227,23 @@ export class IobrokerHandler {
|
|
|
224
227
|
}
|
|
225
228
|
return screen;
|
|
226
229
|
}
|
|
230
|
+
async get3DScreen(name) {
|
|
231
|
+
if (name[0] == '/')
|
|
232
|
+
name = name.substring(1);
|
|
233
|
+
let scene = this.#cache.get('3dscreen').get(name);
|
|
234
|
+
if (!scene) {
|
|
235
|
+
if (this._readyPromises)
|
|
236
|
+
await this.waitForReady();
|
|
237
|
+
try {
|
|
238
|
+
scene = await this._getObjectFromFile(this.configPath + "3dscreens/" + name + '.3dscreen');
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.error("Error reading 3D Screen", scene, err);
|
|
242
|
+
}
|
|
243
|
+
this.#cache.get('3dscreen').set(name, scene);
|
|
244
|
+
}
|
|
245
|
+
return scene;
|
|
246
|
+
}
|
|
227
247
|
async saveObject(type, name, data) {
|
|
228
248
|
await this._saveObjectToFile(data, "/" + this.configPath + type + "s/" + name + '.' + type);
|
|
229
249
|
if (this.#cache.has(type))
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D Scene Store
|
|
3
|
+
*
|
|
4
|
+
* Manages scene state, undo/redo, and persistence
|
|
5
|
+
* Adapts realvirtual's scene store pattern for mywebui
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TypedEvent } from "@gokturk413/base-custom-webcomponent";
|
|
9
|
+
import {
|
|
10
|
+
Scene3D,
|
|
11
|
+
Scene3DSession,
|
|
12
|
+
EditOp,
|
|
13
|
+
Asset,
|
|
14
|
+
Light,
|
|
15
|
+
CameraPreset,
|
|
16
|
+
createDefaultScene,
|
|
17
|
+
SetAssetPropertyOp,
|
|
18
|
+
TransformAssetOp,
|
|
19
|
+
AddAssetOp,
|
|
20
|
+
RemoveAssetOp,
|
|
21
|
+
Vector3
|
|
22
|
+
} from './3d-scene-types';
|
|
23
|
+
import { iobrokerHandler } from "../common/IobrokerHandler.js";
|
|
24
|
+
|
|
25
|
+
export class Scene3DStore {
|
|
26
|
+
private session: Scene3DSession;
|
|
27
|
+
private undoStack: EditOp[] = [];
|
|
28
|
+
private redoStack: EditOp[] = [];
|
|
29
|
+
|
|
30
|
+
sceneChanged = new TypedEvent<Scene3D>();
|
|
31
|
+
selectionChanged = new TypedEvent<string | null>();
|
|
32
|
+
dirty = new TypedEvent<boolean>();
|
|
33
|
+
|
|
34
|
+
private selectedId: string | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(scene: Scene3D) {
|
|
37
|
+
this.session = {
|
|
38
|
+
saved: JSON.parse(JSON.stringify(scene)),
|
|
39
|
+
draft: JSON.parse(JSON.stringify(scene)),
|
|
40
|
+
isDraft: false,
|
|
41
|
+
dirty: false
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Getters ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
getDraft(): Scene3D {
|
|
48
|
+
return this.session.draft;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSaved(): Scene3D | null {
|
|
52
|
+
return this.session.saved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isDirty(): boolean {
|
|
56
|
+
return this.session.dirty;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getSelectedId(): string | null {
|
|
60
|
+
return this.selectedId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Selection ───────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
select(id: string | null): void {
|
|
66
|
+
this.selectedId = id;
|
|
67
|
+
this.selectionChanged.emit(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getSelected(): Asset | Light | null {
|
|
71
|
+
if (!this.selectedId) return null;
|
|
72
|
+
|
|
73
|
+
const asset = this.session.draft.assets.find(a => a.id === this.selectedId);
|
|
74
|
+
if (asset) return asset;
|
|
75
|
+
|
|
76
|
+
const light = this.session.draft.lights.find(l => l.id === this.selectedId);
|
|
77
|
+
return light || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Operations ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
setAssetProperty(assetId: string, property: string, value: any): void {
|
|
83
|
+
const asset = this.session.draft.assets.find(a => a.id === assetId);
|
|
84
|
+
if (!asset) return;
|
|
85
|
+
|
|
86
|
+
const prev = this.getNestedProperty(asset, property);
|
|
87
|
+
this.setNestedProperty(asset, property, value);
|
|
88
|
+
|
|
89
|
+
const op: SetAssetPropertyOp = {
|
|
90
|
+
id: this.generateOpId(),
|
|
91
|
+
ts: Date.now(),
|
|
92
|
+
schemaV: 1,
|
|
93
|
+
kind: 'setAssetProperty',
|
|
94
|
+
assetId,
|
|
95
|
+
property,
|
|
96
|
+
value,
|
|
97
|
+
prev
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this.applyOp(op);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setLightProperty(lightId: string, property: string, value: any): void {
|
|
104
|
+
const light = this.session.draft.lights.find(l => l.id === lightId);
|
|
105
|
+
if (!light) return;
|
|
106
|
+
|
|
107
|
+
const prev = this.getNestedProperty(light, property);
|
|
108
|
+
this.setNestedProperty(light, property, value);
|
|
109
|
+
|
|
110
|
+
const op = {
|
|
111
|
+
id: this.generateOpId(),
|
|
112
|
+
ts: Date.now(),
|
|
113
|
+
schemaV: 1,
|
|
114
|
+
kind: 'setLightProperty' as const,
|
|
115
|
+
lightId,
|
|
116
|
+
property,
|
|
117
|
+
value,
|
|
118
|
+
prev
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
this.applyOp(op);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
transformAsset(assetId: string, position: Vector3, rotation: Vector3, scale: Vector3): void {
|
|
125
|
+
const asset = this.session.draft.assets.find(a => a.id === assetId);
|
|
126
|
+
if (!asset) return;
|
|
127
|
+
|
|
128
|
+
const prev = {
|
|
129
|
+
position: { ...asset.position },
|
|
130
|
+
rotation: { ...asset.rotation },
|
|
131
|
+
scale: { ...asset.scale }
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
asset.position = position;
|
|
135
|
+
asset.rotation = rotation;
|
|
136
|
+
asset.scale = scale;
|
|
137
|
+
|
|
138
|
+
const op: TransformAssetOp = {
|
|
139
|
+
id: this.generateOpId(),
|
|
140
|
+
ts: Date.now(),
|
|
141
|
+
schemaV: 1,
|
|
142
|
+
kind: 'transformAsset',
|
|
143
|
+
assetId,
|
|
144
|
+
position,
|
|
145
|
+
rotation,
|
|
146
|
+
scale,
|
|
147
|
+
prev
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
this.applyOp(op);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
addAsset(asset: Asset): void {
|
|
154
|
+
this.session.draft.assets.push(asset);
|
|
155
|
+
|
|
156
|
+
const op: AddAssetOp = {
|
|
157
|
+
id: this.generateOpId(),
|
|
158
|
+
ts: Date.now(),
|
|
159
|
+
schemaV: 1,
|
|
160
|
+
kind: 'addAsset',
|
|
161
|
+
asset: JSON.parse(JSON.stringify(asset))
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
this.applyOp(op);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
removeAsset(assetId: string): void {
|
|
168
|
+
const index = this.session.draft.assets.findIndex(a => a.id === assetId);
|
|
169
|
+
if (index === -1) return;
|
|
170
|
+
|
|
171
|
+
const asset = this.session.draft.assets[index];
|
|
172
|
+
this.session.draft.assets.splice(index, 1);
|
|
173
|
+
|
|
174
|
+
const op: RemoveAssetOp = {
|
|
175
|
+
id: this.generateOpId(),
|
|
176
|
+
ts: Date.now(),
|
|
177
|
+
schemaV: 1,
|
|
178
|
+
kind: 'removeAsset',
|
|
179
|
+
assetId,
|
|
180
|
+
asset: JSON.parse(JSON.stringify(asset))
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
this.applyOp(op);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setCamera(camera: CameraPreset): void {
|
|
187
|
+
const prev = { ...this.session.draft.camera };
|
|
188
|
+
this.session.draft.camera = camera;
|
|
189
|
+
|
|
190
|
+
const op = {
|
|
191
|
+
id: this.generateOpId(),
|
|
192
|
+
ts: Date.now(),
|
|
193
|
+
schemaV: 1,
|
|
194
|
+
kind: 'setCamera' as const,
|
|
195
|
+
camera,
|
|
196
|
+
prev
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
this.applyOp(op);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Undo/Redo ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
canUndo(): boolean {
|
|
205
|
+
return this.undoStack.length > 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
canRedo(): boolean {
|
|
209
|
+
return this.redoStack.length > 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
undo(): void {
|
|
213
|
+
if (!this.canUndo()) return;
|
|
214
|
+
|
|
215
|
+
const op = this.undoStack.pop()!;
|
|
216
|
+
this.redoStack.push(op);
|
|
217
|
+
this.inverseOp(op);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
redo(): void {
|
|
221
|
+
if (!this.canRedo()) return;
|
|
222
|
+
|
|
223
|
+
const op = this.redoStack.pop()!;
|
|
224
|
+
this.undoStack.push(op);
|
|
225
|
+
this.applyOp(op);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Persistence ────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async save(): Promise<boolean> {
|
|
231
|
+
try {
|
|
232
|
+
const success = await iobrokerHandler.saveObject('3dscreen', this.session.draft.name, this.session.draft);
|
|
233
|
+
if (success) {
|
|
234
|
+
this.session.saved = JSON.parse(JSON.stringify(this.session.draft));
|
|
235
|
+
this.session.dirty = false;
|
|
236
|
+
this.dirty.emit(false);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('Error saving 3D scene:', err);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Private helpers ────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
private applyOp(op: EditOp): void {
|
|
249
|
+
this.undoStack.push(op);
|
|
250
|
+
this.redoStack = [];
|
|
251
|
+
this.session.draft.edits.ops.push(op);
|
|
252
|
+
this.markDirty();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private inverseOp(op: EditOp): void {
|
|
256
|
+
if (op.kind === 'setAssetProperty') {
|
|
257
|
+
const asset = this.session.draft.assets.find(a => a.id === op.assetId);
|
|
258
|
+
if (asset) {
|
|
259
|
+
this.setNestedProperty(asset, op.property, op.prev);
|
|
260
|
+
}
|
|
261
|
+
} else if (op.kind === 'transformAsset') {
|
|
262
|
+
const asset = this.session.draft.assets.find(a => a.id === op.assetId);
|
|
263
|
+
if (asset) {
|
|
264
|
+
asset.position = op.prev.position;
|
|
265
|
+
asset.rotation = op.prev.rotation;
|
|
266
|
+
asset.scale = op.prev.scale;
|
|
267
|
+
}
|
|
268
|
+
} else if (op.kind === 'addAsset') {
|
|
269
|
+
this.session.draft.assets = this.session.draft.assets.filter(a => a.id !== op.asset.id);
|
|
270
|
+
} else if (op.kind === 'removeAsset') {
|
|
271
|
+
this.session.draft.assets.push(op.asset);
|
|
272
|
+
} else if (op.kind === 'setCamera') {
|
|
273
|
+
this.session.draft.camera = op.prev;
|
|
274
|
+
}
|
|
275
|
+
this.markDirty();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private markDirty(): void {
|
|
279
|
+
this.session.dirty = true;
|
|
280
|
+
this.dirty.emit(true);
|
|
281
|
+
this.sceneChanged.emit(this.session.draft);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private getNestedProperty(obj: any, path: string): any {
|
|
285
|
+
return path.split('.').reduce((curr, prop) => curr?.[prop], obj);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private setNestedProperty(obj: any, path: string, value: any): void {
|
|
289
|
+
const parts = path.split('.');
|
|
290
|
+
const last = parts.pop();
|
|
291
|
+
if (!last) return;
|
|
292
|
+
|
|
293
|
+
let current = obj;
|
|
294
|
+
for (const part of parts) {
|
|
295
|
+
if (!current[part]) current[part] = {};
|
|
296
|
+
current = current[part];
|
|
297
|
+
}
|
|
298
|
+
current[last] = value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private generateOpId(): string {
|
|
302
|
+
return 'op_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 6);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D Scene Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* A 3D Scene consists of:
|
|
5
|
+
* - Assets (GLB models with position, rotation, scale)
|
|
6
|
+
* - Lights (ambient, directional, point, spot)
|
|
7
|
+
* - Camera presets
|
|
8
|
+
* - Bindings (signals to properties)
|
|
9
|
+
* - Animations (from GLB or custom)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Vector3 {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
z: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Binding {
|
|
19
|
+
signal?: string;
|
|
20
|
+
scale?: number;
|
|
21
|
+
offset?: number;
|
|
22
|
+
min?: number;
|
|
23
|
+
max?: number;
|
|
24
|
+
type?: 'boolean' | 'color' | 'number' | 'string';
|
|
25
|
+
expression?: string;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Material {
|
|
30
|
+
color?: string;
|
|
31
|
+
emissive?: string;
|
|
32
|
+
metalness?: number;
|
|
33
|
+
roughness?: number;
|
|
34
|
+
opacity?: number;
|
|
35
|
+
[key: string]: any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Animation {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
duration: number;
|
|
42
|
+
loop?: boolean;
|
|
43
|
+
speed?: number;
|
|
44
|
+
binding?: {
|
|
45
|
+
monitoredState?: string;
|
|
46
|
+
behavior?: 'autoplay' | 'monitorstate';
|
|
47
|
+
stateMaxValue?: number;
|
|
48
|
+
startValue?: number;
|
|
49
|
+
repeat?: boolean;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Asset {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
type: 'model' | 'light' | 'control';
|
|
57
|
+
glbPath?: string;
|
|
58
|
+
position: Vector3;
|
|
59
|
+
rotation: Vector3;
|
|
60
|
+
scale: Vector3;
|
|
61
|
+
visible: boolean;
|
|
62
|
+
material?: Material;
|
|
63
|
+
bindings?: Record<string, Binding>;
|
|
64
|
+
animations?: Animation[];
|
|
65
|
+
userData?: Record<string, any>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Light {
|
|
69
|
+
id: string;
|
|
70
|
+
name: string;
|
|
71
|
+
type: 'ambient' | 'directional' | 'point' | 'spot';
|
|
72
|
+
color: string;
|
|
73
|
+
intensity: number;
|
|
74
|
+
position?: Vector3;
|
|
75
|
+
castShadow?: boolean;
|
|
76
|
+
binding?: Record<string, Binding>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CameraPreset {
|
|
80
|
+
position: Vector3;
|
|
81
|
+
target: Vector3;
|
|
82
|
+
fov?: number;
|
|
83
|
+
binding?: Record<string, Binding>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GridConfig {
|
|
87
|
+
visible: boolean;
|
|
88
|
+
size: number;
|
|
89
|
+
divisions: number;
|
|
90
|
+
colorCenterLine: string;
|
|
91
|
+
colorGrid: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AxesConfig {
|
|
95
|
+
visible: boolean;
|
|
96
|
+
size: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SceneSettings {
|
|
100
|
+
backgroundColor: string;
|
|
101
|
+
enableControls: boolean;
|
|
102
|
+
enableRaycasting: boolean;
|
|
103
|
+
enablePropertyPanel: boolean;
|
|
104
|
+
shadowsEnabled: boolean;
|
|
105
|
+
antialiasing: boolean;
|
|
106
|
+
[key: string]: any;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SceneEdits {
|
|
110
|
+
ops: EditOp[];
|
|
111
|
+
workspaceSettings?: Record<string, any>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type EditOp =
|
|
115
|
+
| SetAssetPropertyOp
|
|
116
|
+
| SetLightPropertyOp
|
|
117
|
+
| SetCameraOp
|
|
118
|
+
| AddAssetOp
|
|
119
|
+
| RemoveAssetOp
|
|
120
|
+
| TransformAssetOp
|
|
121
|
+
| AddLightOp
|
|
122
|
+
| RemoveLightOp
|
|
123
|
+
| TransformLightOp;
|
|
124
|
+
|
|
125
|
+
export interface EditOpBase {
|
|
126
|
+
id: string;
|
|
127
|
+
ts: number;
|
|
128
|
+
schemaV: 1;
|
|
129
|
+
selectionAfter?: string | null;
|
|
130
|
+
selectionBefore?: string | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SetAssetPropertyOp extends EditOpBase {
|
|
134
|
+
kind: 'setAssetProperty';
|
|
135
|
+
assetId: string;
|
|
136
|
+
property: string;
|
|
137
|
+
value: any;
|
|
138
|
+
prev: any;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SetLightPropertyOp extends EditOpBase {
|
|
142
|
+
kind: 'setLightProperty';
|
|
143
|
+
lightId: string;
|
|
144
|
+
property: string;
|
|
145
|
+
value: any;
|
|
146
|
+
prev: any;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface SetCameraOp extends EditOpBase {
|
|
150
|
+
kind: 'setCamera';
|
|
151
|
+
camera: CameraPreset;
|
|
152
|
+
prev: CameraPreset;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface AddAssetOp extends EditOpBase {
|
|
156
|
+
kind: 'addAsset';
|
|
157
|
+
asset: Asset;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RemoveAssetOp extends EditOpBase {
|
|
161
|
+
kind: 'removeAsset';
|
|
162
|
+
assetId: string;
|
|
163
|
+
asset: Asset;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TransformAssetOp extends EditOpBase {
|
|
167
|
+
kind: 'transformAsset';
|
|
168
|
+
assetId: string;
|
|
169
|
+
position: Vector3;
|
|
170
|
+
rotation: Vector3;
|
|
171
|
+
scale: Vector3;
|
|
172
|
+
prev: { position: Vector3; rotation: Vector3; scale: Vector3 };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface AddLightOp extends EditOpBase {
|
|
176
|
+
kind: 'addLight';
|
|
177
|
+
light: Light;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface RemoveLightOp extends EditOpBase {
|
|
181
|
+
kind: 'removeLight';
|
|
182
|
+
lightId: string;
|
|
183
|
+
light: Light;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface TransformLightOp extends EditOpBase {
|
|
187
|
+
kind: 'transformLight';
|
|
188
|
+
lightId: string;
|
|
189
|
+
position: Vector3;
|
|
190
|
+
prev: Vector3;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface Scene3D {
|
|
194
|
+
id: string;
|
|
195
|
+
name: string;
|
|
196
|
+
description?: string;
|
|
197
|
+
version: string;
|
|
198
|
+
createdAt: string;
|
|
199
|
+
modifiedAt: string;
|
|
200
|
+
|
|
201
|
+
assets: Asset[];
|
|
202
|
+
lights: Light[];
|
|
203
|
+
camera: CameraPreset;
|
|
204
|
+
|
|
205
|
+
grid: GridConfig;
|
|
206
|
+
axes: AxesConfig;
|
|
207
|
+
settings: SceneSettings;
|
|
208
|
+
|
|
209
|
+
edits: SceneEdits;
|
|
210
|
+
properties?: Record<string, any>;
|
|
211
|
+
bindings?: Record<string, any>;
|
|
212
|
+
|
|
213
|
+
thumbnailDataUrl?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface Scene3DSession {
|
|
217
|
+
saved: Scene3D | null;
|
|
218
|
+
draft: Scene3D;
|
|
219
|
+
isDraft: boolean;
|
|
220
|
+
dirty: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createDefaultScene(id: string, name: string): Scene3D {
|
|
224
|
+
return {
|
|
225
|
+
id,
|
|
226
|
+
name,
|
|
227
|
+
version: '1.0',
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
modifiedAt: new Date().toISOString(),
|
|
230
|
+
assets: [],
|
|
231
|
+
lights: [
|
|
232
|
+
{
|
|
233
|
+
id: 'ambient_light',
|
|
234
|
+
name: 'Ambient Light',
|
|
235
|
+
type: 'ambient',
|
|
236
|
+
color: '#ffffff',
|
|
237
|
+
intensity: 0.6
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'directional_light',
|
|
241
|
+
name: 'Directional Light',
|
|
242
|
+
type: 'directional',
|
|
243
|
+
color: '#ffffff',
|
|
244
|
+
intensity: 0.8,
|
|
245
|
+
position: { x: 10, y: 10, z: 10 },
|
|
246
|
+
castShadow: true
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
camera: {
|
|
250
|
+
position: { x: 10, y: 10, z: 10 },
|
|
251
|
+
target: { x: 0, y: 0, z: 0 },
|
|
252
|
+
fov: 75
|
|
253
|
+
},
|
|
254
|
+
grid: {
|
|
255
|
+
visible: true,
|
|
256
|
+
size: 20,
|
|
257
|
+
divisions: 20,
|
|
258
|
+
colorCenterLine: '#888888',
|
|
259
|
+
colorGrid: '#444444'
|
|
260
|
+
},
|
|
261
|
+
axes: {
|
|
262
|
+
visible: true,
|
|
263
|
+
size: 5
|
|
264
|
+
},
|
|
265
|
+
settings: {
|
|
266
|
+
backgroundColor: '#333333',
|
|
267
|
+
enableControls: true,
|
|
268
|
+
enableRaycasting: true,
|
|
269
|
+
enablePropertyPanel: true,
|
|
270
|
+
shadowsEnabled: true,
|
|
271
|
+
antialiasing: true
|
|
272
|
+
},
|
|
273
|
+
edits: {
|
|
274
|
+
ops: []
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { BaseCustomWebComponentConstructorAppend, LazyLoader, css, html } from "@gokturk413/base-custom-webcomponent";
|
|
2
|
+
import { iobrokerHandler } from "../common/IobrokerHandler.js";
|
|
3
|
+
|
|
4
|
+
export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstructorAppend {
|
|
5
|
+
static template = html`
|
|
6
|
+
<div id="editor-container" style="width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;">
|
|
7
|
+
<div id="toolbar" style="height:40px;background:#2a2a2a;border-bottom:1px solid #444;display:flex;align-items:center;padding:0 10px;gap:10px;">
|
|
8
|
+
<button id="saveBtn" style="padding:5px 15px;background:#0078d4;color:white;border:none;cursor:pointer;border-radius:3px;">Save</button>
|
|
9
|
+
<button id="undoBtn" style="padding:5px 15px;background:#444;color:#aaa;border:none;cursor:pointer;border-radius:3px;">↶ Undo</button>
|
|
10
|
+
<button id="redoBtn" style="padding:5px 15px;background:#444;color:#aaa;border:none;cursor:pointer;border-radius:3px;">↷ Redo</button>
|
|
11
|
+
<div style="flex:1;"></div>
|
|
12
|
+
<button id="gridToggle" style="padding:5px 10px;background:#444;color:#aaa;border:none;cursor:pointer;border-radius:3px;">Grid</button>
|
|
13
|
+
<button id="axesToggle" style="padding:5px 10px;background:#444;color:#aaa;border:none;cursor:pointer;border-radius:3px;">Axes</button>
|
|
14
|
+
</div>
|
|
15
|
+
<div id="mainContent" style="flex:1;display:flex;overflow:hidden;">
|
|
16
|
+
<div id="leftPanel" style="width:250px;background:#1e1e1e;border-right:1px solid #444;overflow:auto;">
|
|
17
|
+
<div style="padding:10px;font-weight:bold;color:#aaa;border-bottom:1px solid #444;">Scene Tree</div>
|
|
18
|
+
<div id="sceneTree" style="padding:10px;"></div>
|
|
19
|
+
</div>
|
|
20
|
+
<div id="viewport" style="flex:1;background:#333;position:relative;overflow:hidden;"></div>
|
|
21
|
+
<div id="rightPanel" style="width:300px;background:#1e1e1e;border-left:1px solid #444;overflow:auto;">
|
|
22
|
+
<div style="padding:10px;font-weight:bold;color:#aaa;border-bottom:1px solid #444;">Properties</div>
|
|
23
|
+
<div id="propertyPanel" style="padding:10px;"></div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
static style = css`
|
|
30
|
+
:host {
|
|
31
|
+
display: block;
|
|
32
|
+
width: 100%;
|
|
33
|
+
height: 100%;
|
|
34
|
+
background: #1a1a1a;
|
|
35
|
+
color: #aaa;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
button:hover {
|
|
39
|
+
background: #555 !important;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
button:active {
|
|
43
|
+
background: #666 !important;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
input, select {
|
|
47
|
+
background: #333;
|
|
48
|
+
color: #aaa;
|
|
49
|
+
border: 1px solid #444;
|
|
50
|
+
padding: 5px;
|
|
51
|
+
border-radius: 3px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.property-group {
|
|
55
|
+
margin-bottom: 15px;
|
|
56
|
+
padding: 10px;
|
|
57
|
+
background: #242424;
|
|
58
|
+
border-radius: 3px;
|
|
59
|
+
border: 1px solid #333;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.property-label {
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
color: #888;
|
|
65
|
+
margin-bottom: 5px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.property-input {
|
|
69
|
+
width: 100%;
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
constructor() {
|
|
75
|
+
super();
|
|
76
|
+
this.scene = null;
|
|
77
|
+
this.camera = null;
|
|
78
|
+
this.renderer = null;
|
|
79
|
+
this.controls = null;
|
|
80
|
+
this.raycaster = null;
|
|
81
|
+
this.mouse = null;
|
|
82
|
+
this.THREE = null;
|
|
83
|
+
this.gltfLoader = null;
|
|
84
|
+
this.loadedModel = null;
|
|
85
|
+
this.selectedObject = null;
|
|
86
|
+
this.sceneData = null;
|
|
87
|
+
this.sceneStore = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async connectedCallback() {
|
|
91
|
+
await this.init();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async init() {
|
|
95
|
+
try {
|
|
96
|
+
const statusEl = document.createElement('div');
|
|
97
|
+
statusEl.textContent = '⏳ Loading Three.js...';
|
|
98
|
+
statusEl.style.cssText = 'position:absolute;top:10px;left:10px;background:rgba(0,0,0,0.7);color:#0f0;padding:10px;font-family:monospace;z-index:100;';
|
|
99
|
+
this._getDomElement('viewport').appendChild(statusEl);
|
|
100
|
+
|
|
101
|
+
// Load Three.js
|
|
102
|
+
const THREE = await import('/mywebui.0.widgets/node_modules/three/build/three.module.js');
|
|
103
|
+
const { OrbitControls } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js');
|
|
104
|
+
const { GLTFLoader } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js');
|
|
105
|
+
|
|
106
|
+
this.THREE = THREE;
|
|
107
|
+
this.gltfLoader = new GLTFLoader();
|
|
108
|
+
|
|
109
|
+
statusEl.textContent = '⏳ Initializing scene...';
|
|
110
|
+
|
|
111
|
+
// Initialize Three.js
|
|
112
|
+
this.initThreeJS(THREE, OrbitControls);
|
|
113
|
+
|
|
114
|
+
// Load scene data
|
|
115
|
+
const sceneName = this.getAttribute('scene-name') || 'new-scene';
|
|
116
|
+
await this.loadScene(sceneName);
|
|
117
|
+
|
|
118
|
+
statusEl.textContent = '✅ 3D Editor Ready';
|
|
119
|
+
setTimeout(() => statusEl.remove(), 2000);
|
|
120
|
+
|
|
121
|
+
this.setupEventListeners();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('❌ 3D Editor init failed:', error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
initThreeJS(THREE, OrbitControls) {
|
|
128
|
+
const viewport = this._getDomElement('viewport');
|
|
129
|
+
const width = viewport.clientWidth;
|
|
130
|
+
const height = viewport.clientHeight;
|
|
131
|
+
|
|
132
|
+
// Scene
|
|
133
|
+
this.scene = new THREE.Scene();
|
|
134
|
+
this.scene.background = new THREE.Color(0x333333);
|
|
135
|
+
|
|
136
|
+
// Camera
|
|
137
|
+
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
138
|
+
this.camera.position.set(10, 10, 10);
|
|
139
|
+
|
|
140
|
+
// Renderer
|
|
141
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
142
|
+
this.renderer.setSize(width, height);
|
|
143
|
+
this.renderer.shadowMap.enabled = true;
|
|
144
|
+
viewport.appendChild(this.renderer.domElement);
|
|
145
|
+
|
|
146
|
+
// Lighting
|
|
147
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
148
|
+
this.scene.add(ambientLight);
|
|
149
|
+
|
|
150
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
151
|
+
directionalLight.position.set(10, 10, 10);
|
|
152
|
+
directionalLight.castShadow = true;
|
|
153
|
+
directionalLight.shadow.mapSize.width = 2048;
|
|
154
|
+
directionalLight.shadow.mapSize.height = 2048;
|
|
155
|
+
this.scene.add(directionalLight);
|
|
156
|
+
|
|
157
|
+
// Grid
|
|
158
|
+
const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0x444444);
|
|
159
|
+
gridHelper.position.y = -0.01;
|
|
160
|
+
gridHelper.userData.isGrid = true;
|
|
161
|
+
this.scene.add(gridHelper);
|
|
162
|
+
|
|
163
|
+
// Axes
|
|
164
|
+
const axesHelper = new THREE.AxesHelper(5);
|
|
165
|
+
axesHelper.userData.isAxes = true;
|
|
166
|
+
this.scene.add(axesHelper);
|
|
167
|
+
|
|
168
|
+
// Controls
|
|
169
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
170
|
+
this.controls.autoRotate = false;
|
|
171
|
+
this.controls.enableZoom = true;
|
|
172
|
+
this.controls.enablePan = true;
|
|
173
|
+
|
|
174
|
+
// Raycasting
|
|
175
|
+
this.raycaster = new THREE.Raycaster();
|
|
176
|
+
this.mouse = new THREE.Vector2();
|
|
177
|
+
|
|
178
|
+
// Animation loop
|
|
179
|
+
const animate = () => {
|
|
180
|
+
requestAnimationFrame(animate);
|
|
181
|
+
this.controls.update();
|
|
182
|
+
this.renderer.render(this.scene, this.camera);
|
|
183
|
+
};
|
|
184
|
+
animate();
|
|
185
|
+
|
|
186
|
+
// Mouse click
|
|
187
|
+
this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
|
|
188
|
+
|
|
189
|
+
// Resize
|
|
190
|
+
window.addEventListener('resize', () => this.onWindowResize());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async loadScene(sceneName) {
|
|
194
|
+
try {
|
|
195
|
+
this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
|
|
196
|
+
if (!this.sceneData) {
|
|
197
|
+
console.warn(`Scene not found: ${sceneName}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('✅ Scene loaded:', this.sceneData);
|
|
202
|
+
|
|
203
|
+
// Update viewport
|
|
204
|
+
this.updateSceneView();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error('Error loading scene:', error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
updateSceneView() {
|
|
211
|
+
if (!this.sceneData) return;
|
|
212
|
+
|
|
213
|
+
// Add assets to scene
|
|
214
|
+
for (const asset of this.sceneData.assets) {
|
|
215
|
+
if (asset.type === 'model' && asset.glbPath) {
|
|
216
|
+
this.loadAsset(asset);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Update lights
|
|
221
|
+
for (const light of this.sceneData.lights) {
|
|
222
|
+
this.addLightToScene(light);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Update camera
|
|
226
|
+
if (this.sceneData.camera) {
|
|
227
|
+
this.camera.position.set(
|
|
228
|
+
this.sceneData.camera.position.x,
|
|
229
|
+
this.sceneData.camera.position.y,
|
|
230
|
+
this.sceneData.camera.position.z
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Update scene tree
|
|
235
|
+
this.updateSceneTree();
|
|
236
|
+
|
|
237
|
+
// Update property panel
|
|
238
|
+
this.updatePropertyPanel();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
loadAsset(asset) {
|
|
242
|
+
if (!asset.glbPath) return;
|
|
243
|
+
|
|
244
|
+
this.gltfLoader.load(
|
|
245
|
+
asset.glbPath,
|
|
246
|
+
(gltf) => {
|
|
247
|
+
const model = gltf.scene;
|
|
248
|
+
model.userData.assetId = asset.id;
|
|
249
|
+
model.userData.assetData = asset;
|
|
250
|
+
model.position.set(asset.position.x, asset.position.y, asset.position.z);
|
|
251
|
+
model.rotation.set(asset.rotation.x, asset.rotation.y, asset.rotation.z);
|
|
252
|
+
model.scale.set(asset.scale.x, asset.scale.y, asset.scale.z);
|
|
253
|
+
this.scene.add(model);
|
|
254
|
+
console.log('✅ Asset loaded:', asset.name);
|
|
255
|
+
},
|
|
256
|
+
undefined,
|
|
257
|
+
(error) => console.error('Error loading asset:', error)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
addLightToScene(light) {
|
|
262
|
+
let threeLight;
|
|
263
|
+
|
|
264
|
+
switch (light.type) {
|
|
265
|
+
case 'ambient':
|
|
266
|
+
threeLight = new this.THREE.AmbientLight(light.color, light.intensity);
|
|
267
|
+
break;
|
|
268
|
+
case 'directional':
|
|
269
|
+
threeLight = new this.THREE.DirectionalLight(light.color, light.intensity);
|
|
270
|
+
if (light.position) {
|
|
271
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
272
|
+
}
|
|
273
|
+
if (light.castShadow) {
|
|
274
|
+
threeLight.castShadow = true;
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
case 'point':
|
|
278
|
+
threeLight = new this.THREE.PointLight(light.color, light.intensity);
|
|
279
|
+
if (light.position) {
|
|
280
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
case 'spot':
|
|
284
|
+
threeLight = new this.THREE.SpotLight(light.color, light.intensity);
|
|
285
|
+
if (light.position) {
|
|
286
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (threeLight) {
|
|
292
|
+
threeLight.userData.lightId = light.id;
|
|
293
|
+
threeLight.userData.lightData = light;
|
|
294
|
+
this.scene.add(threeLight);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
onMouseClick(event) {
|
|
299
|
+
if (!this.scene || !this.camera) return;
|
|
300
|
+
|
|
301
|
+
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
302
|
+
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
303
|
+
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
304
|
+
|
|
305
|
+
this.raycaster.setFromCamera(this.mouse, this.camera);
|
|
306
|
+
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
|
|
307
|
+
|
|
308
|
+
for (const intersection of intersects) {
|
|
309
|
+
const obj = intersection.object;
|
|
310
|
+
if (obj.userData.assetId) {
|
|
311
|
+
this.selectObject(obj);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
selectObject(obj) {
|
|
318
|
+
if (this.selectedObject === obj) return;
|
|
319
|
+
|
|
320
|
+
// Deselect previous
|
|
321
|
+
if (this.selectedObject) {
|
|
322
|
+
this.selectedObject.userData.selected = false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Select new
|
|
326
|
+
this.selectedObject = obj;
|
|
327
|
+
obj.userData.selected = true;
|
|
328
|
+
|
|
329
|
+
this.updatePropertyPanel();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
updateSceneTree() {
|
|
333
|
+
const treeEl = this._getDomElement('sceneTree');
|
|
334
|
+
treeEl.innerHTML = '';
|
|
335
|
+
|
|
336
|
+
// Assets
|
|
337
|
+
if (this.sceneData.assets && this.sceneData.assets.length > 0) {
|
|
338
|
+
const assetsDiv = document.createElement('div');
|
|
339
|
+
assetsDiv.style.cssText = 'margin-bottom:10px;';
|
|
340
|
+
|
|
341
|
+
const assetsTitle = document.createElement('div');
|
|
342
|
+
assetsTitle.textContent = '📦 Assets';
|
|
343
|
+
assetsTitle.style.cssText = 'color:#0f0;margin-bottom:5px;font-weight:bold;';
|
|
344
|
+
assetsDiv.appendChild(assetsTitle);
|
|
345
|
+
|
|
346
|
+
for (const asset of this.sceneData.assets) {
|
|
347
|
+
const itemDiv = document.createElement('div');
|
|
348
|
+
itemDiv.textContent = asset.name;
|
|
349
|
+
itemDiv.style.cssText = 'padding:5px;cursor:pointer;color:#aaa;margin-left:10px;';
|
|
350
|
+
itemDiv.addEventListener('click', () => console.log('Select asset:', asset.id));
|
|
351
|
+
assetsDiv.appendChild(itemDiv);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
treeEl.appendChild(assetsDiv);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Lights
|
|
358
|
+
if (this.sceneData.lights && this.sceneData.lights.length > 0) {
|
|
359
|
+
const lightsDiv = document.createElement('div');
|
|
360
|
+
lightsDiv.style.cssText = 'margin-bottom:10px;';
|
|
361
|
+
|
|
362
|
+
const lightsTitle = document.createElement('div');
|
|
363
|
+
lightsTitle.textContent = '💡 Lights';
|
|
364
|
+
lightsTitle.style.cssText = 'color:#ff0;margin-bottom:5px;font-weight:bold;';
|
|
365
|
+
lightsDiv.appendChild(lightsTitle);
|
|
366
|
+
|
|
367
|
+
for (const light of this.sceneData.lights) {
|
|
368
|
+
const itemDiv = document.createElement('div');
|
|
369
|
+
itemDiv.textContent = light.name;
|
|
370
|
+
itemDiv.style.cssText = 'padding:5px;cursor:pointer;color:#aaa;margin-left:10px;';
|
|
371
|
+
lightsDiv.appendChild(itemDiv);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
treeEl.appendChild(lightsDiv);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
updatePropertyPanel() {
|
|
379
|
+
const panelEl = this._getDomElement('propertyPanel');
|
|
380
|
+
panelEl.innerHTML = '';
|
|
381
|
+
|
|
382
|
+
if (!this.selectedObject || !this.selectedObject.userData.assetData) {
|
|
383
|
+
panelEl.innerHTML = '<div style="color:#888;">No selection</div>';
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const asset = this.selectedObject.userData.assetData;
|
|
388
|
+
|
|
389
|
+
// Position
|
|
390
|
+
const posGroup = document.createElement('div');
|
|
391
|
+
posGroup.className = 'property-group';
|
|
392
|
+
posGroup.innerHTML = `
|
|
393
|
+
<div class="property-label">Position</div>
|
|
394
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;">
|
|
395
|
+
<input type="number" id="posX" value="${asset.position.x}" step="0.1" class="property-input" placeholder="X">
|
|
396
|
+
<input type="number" id="posY" value="${asset.position.y}" step="0.1" class="property-input" placeholder="Y">
|
|
397
|
+
<input type="number" id="posZ" value="${asset.position.z}" step="0.1" class="property-input" placeholder="Z">
|
|
398
|
+
</div>
|
|
399
|
+
`;
|
|
400
|
+
panelEl.appendChild(posGroup);
|
|
401
|
+
|
|
402
|
+
// Rotation
|
|
403
|
+
const rotGroup = document.createElement('div');
|
|
404
|
+
rotGroup.className = 'property-group';
|
|
405
|
+
rotGroup.innerHTML = `
|
|
406
|
+
<div class="property-label">Rotation</div>
|
|
407
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;">
|
|
408
|
+
<input type="number" id="rotX" value="${asset.rotation.x}" step="0.1" class="property-input" placeholder="X">
|
|
409
|
+
<input type="number" id="rotY" value="${asset.rotation.y}" step="0.1" class="property-input" placeholder="Y">
|
|
410
|
+
<input type="number" id="rotZ" value="${asset.rotation.z}" step="0.1" class="property-input" placeholder="Z">
|
|
411
|
+
</div>
|
|
412
|
+
`;
|
|
413
|
+
panelEl.appendChild(rotGroup);
|
|
414
|
+
|
|
415
|
+
// Scale
|
|
416
|
+
const scaleGroup = document.createElement('div');
|
|
417
|
+
scaleGroup.className = 'property-group';
|
|
418
|
+
scaleGroup.innerHTML = `
|
|
419
|
+
<div class="property-label">Scale</div>
|
|
420
|
+
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;">
|
|
421
|
+
<input type="number" id="scaleX" value="${asset.scale.x}" step="0.1" class="property-input" placeholder="X">
|
|
422
|
+
<input type="number" id="scaleY" value="${asset.scale.y}" step="0.1" class="property-input" placeholder="Y">
|
|
423
|
+
<input type="number" id="scaleZ" value="${asset.scale.z}" step="0.1" class="property-input" placeholder="Z">
|
|
424
|
+
</div>
|
|
425
|
+
`;
|
|
426
|
+
panelEl.appendChild(scaleGroup);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
setupEventListeners() {
|
|
430
|
+
this._getDomElement('saveBtn').addEventListener('click', () => this.saveScene());
|
|
431
|
+
this._getDomElement('undoBtn').addEventListener('click', () => console.log('Undo'));
|
|
432
|
+
this._getDomElement('redoBtn').addEventListener('click', () => console.log('Redo'));
|
|
433
|
+
this._getDomElement('gridToggle').addEventListener('click', () => this.toggleGrid());
|
|
434
|
+
this._getDomElement('axesToggle').addEventListener('click', () => this.toggleAxes());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async saveScene() {
|
|
438
|
+
try {
|
|
439
|
+
const success = await iobrokerHandler.saveObject('3dscreen', this.sceneData.name, this.sceneData);
|
|
440
|
+
if (success) {
|
|
441
|
+
console.log('✅ Scene saved:', this.sceneData.name);
|
|
442
|
+
}
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error('Error saving scene:', error);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
toggleGrid() {
|
|
449
|
+
const grid = this.scene.children.find(c => c.userData.isGrid);
|
|
450
|
+
if (grid) {
|
|
451
|
+
grid.visible = !grid.visible;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
toggleAxes() {
|
|
456
|
+
const axes = this.scene.children.find(c => c.userData.isAxes);
|
|
457
|
+
if (axes) {
|
|
458
|
+
axes.visible = !axes.visible;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
onWindowResize() {
|
|
463
|
+
const viewport = this._getDomElement('viewport');
|
|
464
|
+
const width = viewport.clientWidth;
|
|
465
|
+
const height = viewport.clientHeight;
|
|
466
|
+
|
|
467
|
+
this.camera.aspect = width / height;
|
|
468
|
+
this.camera.updateProjectionMatrix();
|
|
469
|
+
this.renderer.setSize(width, height);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
disconnectedCallback() {
|
|
473
|
+
if (this.renderer) {
|
|
474
|
+
this.renderer.dispose();
|
|
475
|
+
this.renderer.domElement.remove();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
customElements.define('iobroker-webui-3dscreen-editor', IobrokerWebui3DScreenEditor);
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { BaseCustomWebComponentConstructorAppend, css, html } from "@gokturk413/base-custom-webcomponent";
|
|
2
|
+
import { iobrokerHandler } from "../common/IobrokerHandler.js";
|
|
3
|
+
|
|
4
|
+
export class IobrokerWebui3DScreenViewer extends BaseCustomWebComponentConstructorAppend {
|
|
5
|
+
static template = html`
|
|
6
|
+
<div id="container" style="width:100%;height:100%;position:relative;overflow:hidden;background:#333;">
|
|
7
|
+
<div id="viewport" style="width:100%;height:100%;"></div>
|
|
8
|
+
<div id="status" style="position:absolute;bottom:10px;left:10px;background:rgba(0,0,0,0.7);color:#0f0;padding:10px;font-family:monospace;font-size:12px;z-index:10;border-radius:3px;">Ready</div>
|
|
9
|
+
</div>
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
static style = css`
|
|
13
|
+
:host {
|
|
14
|
+
display: block;
|
|
15
|
+
width: 100%;
|
|
16
|
+
height: 100%;
|
|
17
|
+
background: #333;
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this.scene = null;
|
|
24
|
+
this.camera = null;
|
|
25
|
+
this.renderer = null;
|
|
26
|
+
this.controls = null;
|
|
27
|
+
this.THREE = null;
|
|
28
|
+
this.gltfLoader = null;
|
|
29
|
+
this.sceneData = null;
|
|
30
|
+
this.sceneNameAttr = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async connectedCallback() {
|
|
34
|
+
this.sceneNameAttr = this.getAttribute('scene-name');
|
|
35
|
+
if (!this.sceneNameAttr) {
|
|
36
|
+
this._getDomElement('status').textContent = '❌ No scene name';
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await this.init();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async init() {
|
|
44
|
+
try {
|
|
45
|
+
const statusEl = this._getDomElement('status');
|
|
46
|
+
statusEl.textContent = '⏳ Loading...';
|
|
47
|
+
|
|
48
|
+
// Load Three.js
|
|
49
|
+
const THREE = await import('/mywebui.0.widgets/node_modules/three/build/three.module.js');
|
|
50
|
+
const { OrbitControls } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js');
|
|
51
|
+
const { GLTFLoader } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js');
|
|
52
|
+
|
|
53
|
+
this.THREE = THREE;
|
|
54
|
+
this.gltfLoader = new GLTFLoader();
|
|
55
|
+
|
|
56
|
+
// Initialize Three.js
|
|
57
|
+
this.initThreeJS(THREE, OrbitControls);
|
|
58
|
+
|
|
59
|
+
// Load scene
|
|
60
|
+
await this.loadScene(this.sceneNameAttr);
|
|
61
|
+
|
|
62
|
+
statusEl.textContent = '✅ Ready';
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('❌ 3D Viewer init failed:', error);
|
|
65
|
+
this._getDomElement('status').textContent = '❌ ' + error.message;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
initThreeJS(THREE, OrbitControls) {
|
|
70
|
+
const viewport = this._getDomElement('viewport');
|
|
71
|
+
const width = viewport.clientWidth;
|
|
72
|
+
const height = viewport.clientHeight;
|
|
73
|
+
|
|
74
|
+
// Scene
|
|
75
|
+
this.scene = new THREE.Scene();
|
|
76
|
+
this.scene.background = new THREE.Color(0x333333);
|
|
77
|
+
|
|
78
|
+
// Camera
|
|
79
|
+
this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
80
|
+
this.camera.position.set(10, 10, 10);
|
|
81
|
+
|
|
82
|
+
// Renderer
|
|
83
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
84
|
+
this.renderer.setSize(width, height);
|
|
85
|
+
this.renderer.shadowMap.enabled = true;
|
|
86
|
+
viewport.appendChild(this.renderer.domElement);
|
|
87
|
+
|
|
88
|
+
// Lighting
|
|
89
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
90
|
+
this.scene.add(ambientLight);
|
|
91
|
+
|
|
92
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
93
|
+
directionalLight.position.set(10, 10, 10);
|
|
94
|
+
directionalLight.castShadow = true;
|
|
95
|
+
directionalLight.shadow.mapSize.width = 2048;
|
|
96
|
+
directionalLight.shadow.mapSize.height = 2048;
|
|
97
|
+
this.scene.add(directionalLight);
|
|
98
|
+
|
|
99
|
+
// Grid
|
|
100
|
+
const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0x444444);
|
|
101
|
+
gridHelper.position.y = -0.01;
|
|
102
|
+
this.scene.add(gridHelper);
|
|
103
|
+
|
|
104
|
+
// Axes
|
|
105
|
+
const axesHelper = new THREE.AxesHelper(5);
|
|
106
|
+
this.scene.add(axesHelper);
|
|
107
|
+
|
|
108
|
+
// Controls
|
|
109
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
110
|
+
this.controls.autoRotate = false;
|
|
111
|
+
this.controls.enableZoom = true;
|
|
112
|
+
this.controls.enablePan = true;
|
|
113
|
+
|
|
114
|
+
// Animation loop
|
|
115
|
+
let animating = true;
|
|
116
|
+
const animate = () => {
|
|
117
|
+
if (animating) {
|
|
118
|
+
requestAnimationFrame(animate);
|
|
119
|
+
this.controls.update();
|
|
120
|
+
this.renderer.render(this.scene, this.camera);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
animate();
|
|
124
|
+
|
|
125
|
+
// Resize
|
|
126
|
+
window.addEventListener('resize', () => this.onWindowResize());
|
|
127
|
+
|
|
128
|
+
this._stopAnimation = () => { animating = false; };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async loadScene(sceneName) {
|
|
132
|
+
try {
|
|
133
|
+
this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
|
|
134
|
+
if (!this.sceneData) {
|
|
135
|
+
throw new Error(`Scene not found: ${sceneName}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Load assets
|
|
139
|
+
if (this.sceneData.assets) {
|
|
140
|
+
for (const asset of this.sceneData.assets) {
|
|
141
|
+
if (asset.type === 'model' && asset.glbPath) {
|
|
142
|
+
await this.loadAsset(asset);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add lights
|
|
148
|
+
if (this.sceneData.lights) {
|
|
149
|
+
for (const light of this.sceneData.lights) {
|
|
150
|
+
this.addLightToScene(light);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Set camera
|
|
155
|
+
if (this.sceneData.camera) {
|
|
156
|
+
this.camera.position.set(
|
|
157
|
+
this.sceneData.camera.position.x,
|
|
158
|
+
this.sceneData.camera.position.y,
|
|
159
|
+
this.sceneData.camera.position.z
|
|
160
|
+
);
|
|
161
|
+
this.camera.lookAt(
|
|
162
|
+
this.sceneData.camera.target.x,
|
|
163
|
+
this.sceneData.camera.target.y,
|
|
164
|
+
this.sceneData.camera.target.z
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log('✅ Scene loaded:', sceneName);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Error loading scene:', error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
loadAsset(asset) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
this.gltfLoader.load(
|
|
178
|
+
asset.glbPath,
|
|
179
|
+
(gltf) => {
|
|
180
|
+
const model = gltf.scene;
|
|
181
|
+
model.position.set(asset.position.x, asset.position.y, asset.position.z);
|
|
182
|
+
model.rotation.set(asset.rotation.x, asset.rotation.y, asset.rotation.z);
|
|
183
|
+
model.scale.set(asset.scale.x, asset.scale.y, asset.scale.z);
|
|
184
|
+
this.scene.add(model);
|
|
185
|
+
console.log('✅ Asset loaded:', asset.name);
|
|
186
|
+
resolve();
|
|
187
|
+
},
|
|
188
|
+
undefined,
|
|
189
|
+
(error) => {
|
|
190
|
+
console.error('Error loading asset:', error);
|
|
191
|
+
reject(error);
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
addLightToScene(light) {
|
|
198
|
+
let threeLight;
|
|
199
|
+
|
|
200
|
+
switch (light.type) {
|
|
201
|
+
case 'ambient':
|
|
202
|
+
threeLight = new this.THREE.AmbientLight(light.color, light.intensity);
|
|
203
|
+
break;
|
|
204
|
+
case 'directional':
|
|
205
|
+
threeLight = new this.THREE.DirectionalLight(light.color, light.intensity);
|
|
206
|
+
if (light.position) {
|
|
207
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
208
|
+
}
|
|
209
|
+
if (light.castShadow) {
|
|
210
|
+
threeLight.castShadow = true;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case 'point':
|
|
214
|
+
threeLight = new this.THREE.PointLight(light.color, light.intensity);
|
|
215
|
+
if (light.position) {
|
|
216
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
case 'spot':
|
|
220
|
+
threeLight = new this.THREE.SpotLight(light.color, light.intensity);
|
|
221
|
+
if (light.position) {
|
|
222
|
+
threeLight.position.set(light.position.x, light.position.y, light.position.z);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (threeLight) {
|
|
228
|
+
this.scene.add(threeLight);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
onWindowResize() {
|
|
233
|
+
const viewport = this._getDomElement('viewport');
|
|
234
|
+
const width = viewport.clientWidth;
|
|
235
|
+
const height = viewport.clientHeight;
|
|
236
|
+
|
|
237
|
+
this.camera.aspect = width / height;
|
|
238
|
+
this.camera.updateProjectionMatrix();
|
|
239
|
+
this.renderer.setSize(width, height);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
disconnectedCallback() {
|
|
243
|
+
if (this._stopAnimation) this._stopAnimation();
|
|
244
|
+
if (this.renderer) {
|
|
245
|
+
this.renderer.dispose();
|
|
246
|
+
this.renderer.domElement.remove();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
customElements.define('iobroker-webui-3dscreen-viewer', IobrokerWebui3DScreenViewer);
|
|
@@ -99,6 +99,7 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
|
|
|
99
99
|
async createTreeNodes() {
|
|
100
100
|
const result = await Promise.allSettled([
|
|
101
101
|
this._createFolderNode('screen'),
|
|
102
|
+
this._createFolderNode('3dscreen'),
|
|
102
103
|
this._createControlsNode(),
|
|
103
104
|
this._createGlobalNode(),
|
|
104
105
|
this._createNpmsNode(),
|
|
@@ -199,6 +200,9 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
|
|
|
199
200
|
case 'screen':
|
|
200
201
|
name = "Screens";
|
|
201
202
|
break;
|
|
203
|
+
case '3dscreen':
|
|
204
|
+
name = "3D Screens";
|
|
205
|
+
break;
|
|
202
206
|
//case '':
|
|
203
207
|
// name='Additional Files';
|
|
204
208
|
// break;
|
|
@@ -301,6 +305,11 @@ export class IobrokerWebuiSolutionExplorer extends BaseCustomWebComponentConstru
|
|
|
301
305
|
else if (type == 'control') {
|
|
302
306
|
window.appShell.openScreenEditor(nm, type, s.html, s.style, s.script, s.settings, s.properties);
|
|
303
307
|
}
|
|
308
|
+
else if (type == '3dscreen') {
|
|
309
|
+
const editor = document.createElement('iobroker-webui-3dscreen-editor');
|
|
310
|
+
editor.setAttribute('scene-name', nm);
|
|
311
|
+
window.appShell.openDialog(editor, { x: 50, y: 50, width: 1200, height: 800 });
|
|
312
|
+
}
|
|
304
313
|
});
|
|
305
314
|
},
|
|
306
315
|
data: { type, name: (dir ?? '') + '/' + x }
|