iobroker.mywebui 1.42.13 → 1.42.15
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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D Drive Engine for mywebui 3D Screen Editor
|
|
3
|
+
*
|
|
4
|
+
* Kinematics system inspired by realvirtual's architecture.
|
|
5
|
+
* Written from scratch - no code copied, same logic independently implemented.
|
|
6
|
+
*
|
|
7
|
+
* Concepts:
|
|
8
|
+
* Drive - moves a Three.js Object3D along an axis (linear mm or rotation deg)
|
|
9
|
+
* SignalBus - subscribes to ioBroker signals via iobrokerHandler
|
|
10
|
+
* DriveCylinder - extends/retracts between MinPos/MaxPos on ioBroker bool signals
|
|
11
|
+
* DriveSimple - jogs continuously Forward/Backward on ioBroker bool signals
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { iobrokerHandler } from '../common/IobrokerHandler.js';
|
|
15
|
+
|
|
16
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const MM_TO_M = 0.001;
|
|
19
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
20
|
+
|
|
21
|
+
// ── Drive directions ─────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export const DriveDirection = {
|
|
24
|
+
LinearX: 'LinearX',
|
|
25
|
+
LinearY: 'LinearY',
|
|
26
|
+
LinearZ: 'LinearZ',
|
|
27
|
+
RotationX: 'RotationX',
|
|
28
|
+
RotationY: 'RotationY',
|
|
29
|
+
RotationZ: 'RotationZ',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isRotation(dir) {
|
|
33
|
+
return dir === DriveDirection.RotationX ||
|
|
34
|
+
dir === DriveDirection.RotationY ||
|
|
35
|
+
dir === DriveDirection.RotationZ;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Signal Bus ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* SignalBus - bridges ioBroker states to the drive engine.
|
|
42
|
+
* Subscribes to ioBroker signals and dispatches value changes to listeners.
|
|
43
|
+
* ioBrokerHandler.subscribeState is used for live updates.
|
|
44
|
+
*/
|
|
45
|
+
export class SignalBus {
|
|
46
|
+
constructor() {
|
|
47
|
+
/** signalPath → current value */
|
|
48
|
+
this._values = new Map();
|
|
49
|
+
/** signalPath → Set<callback> */
|
|
50
|
+
this._listeners = new Map();
|
|
51
|
+
/** signalPath → unsubscribe function */
|
|
52
|
+
this._unsubs = new Map();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Subscribe to an ioBroker signal. Returns unsubscribe function. */
|
|
56
|
+
subscribe(signalPath, cb) {
|
|
57
|
+
if (!signalPath) return () => {};
|
|
58
|
+
|
|
59
|
+
// Register listener
|
|
60
|
+
if (!this._listeners.has(signalPath)) {
|
|
61
|
+
this._listeners.set(signalPath, new Set());
|
|
62
|
+
}
|
|
63
|
+
this._listeners.get(signalPath).add(cb);
|
|
64
|
+
|
|
65
|
+
// Subscribe to ioBroker if not already
|
|
66
|
+
if (!this._unsubs.has(signalPath)) {
|
|
67
|
+
const unsub = iobrokerHandler.subscribe(signalPath, (id, state) => {
|
|
68
|
+
const val = state?.val ?? false;
|
|
69
|
+
this._values.set(signalPath, val);
|
|
70
|
+
const subs = this._listeners.get(signalPath);
|
|
71
|
+
if (subs) for (const fn of subs) fn(val);
|
|
72
|
+
});
|
|
73
|
+
this._unsubs.set(signalPath, unsub);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fire immediately with current value if known
|
|
77
|
+
if (this._values.has(signalPath)) {
|
|
78
|
+
cb(this._values.get(signalPath));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
const subs = this._listeners.get(signalPath);
|
|
83
|
+
if (subs) {
|
|
84
|
+
subs.delete(cb);
|
|
85
|
+
if (subs.size === 0) {
|
|
86
|
+
this._listeners.delete(signalPath);
|
|
87
|
+
const unsub = this._unsubs.get(signalPath);
|
|
88
|
+
if (unsub) unsub();
|
|
89
|
+
this._unsubs.delete(signalPath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Get current value of a signal (or undefined) */
|
|
96
|
+
get(signalPath) {
|
|
97
|
+
return this._values.get(signalPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Write a value back to ioBroker (for feedback signals) */
|
|
101
|
+
set(signalPath, value) {
|
|
102
|
+
if (!signalPath) return;
|
|
103
|
+
iobrokerHandler.setState(signalPath, value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Dispose all subscriptions */
|
|
107
|
+
dispose() {
|
|
108
|
+
for (const unsub of this._unsubs.values()) {
|
|
109
|
+
try { unsub(); } catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
this._values.clear();
|
|
112
|
+
this._listeners.clear();
|
|
113
|
+
this._unsubs.clear();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Drive ────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Drive - moves a Three.js Object3D along a single axis.
|
|
121
|
+
*
|
|
122
|
+
* Position units: mm for linear, degrees for rotation.
|
|
123
|
+
* Three.js units: meters for linear, radians for rotation.
|
|
124
|
+
*/
|
|
125
|
+
export class Drive {
|
|
126
|
+
/**
|
|
127
|
+
* @param {Object3D} node - Three.js object to animate
|
|
128
|
+
* @param {object} config - drive configuration from asset.drives[]
|
|
129
|
+
*/
|
|
130
|
+
constructor(node, config = {}) {
|
|
131
|
+
this.node = node;
|
|
132
|
+
this.name = node.name || 'Drive';
|
|
133
|
+
|
|
134
|
+
// Config (from asset JSON)
|
|
135
|
+
this.direction = config.direction ?? DriveDirection.LinearX;
|
|
136
|
+
this.reverseDir = config.reverseDir ?? false;
|
|
137
|
+
this.offset = config.offset ?? 0;
|
|
138
|
+
this.startPosition = config.startPosition ?? 0;
|
|
139
|
+
this.targetSpeed = config.targetSpeed ?? 100; // mm/s or deg/s
|
|
140
|
+
this.acceleration = config.acceleration ?? 0; // 0 = instant
|
|
141
|
+
this.useLimits = config.useLimits ?? false;
|
|
142
|
+
this.lowerLimit = config.lowerLimit ?? -180;
|
|
143
|
+
this.upperLimit = config.upperLimit ?? 180;
|
|
144
|
+
|
|
145
|
+
// Runtime state
|
|
146
|
+
this.currentPosition = this.startPosition;
|
|
147
|
+
this.targetPosition = this.startPosition;
|
|
148
|
+
this.currentSpeed = 0;
|
|
149
|
+
this.isRunning = false;
|
|
150
|
+
this.jogForward = false;
|
|
151
|
+
this.jogBackward = false;
|
|
152
|
+
|
|
153
|
+
// Base transform (rest position)
|
|
154
|
+
this._basePos = node.position.clone();
|
|
155
|
+
this._baseQuat = node.quaternion.clone();
|
|
156
|
+
|
|
157
|
+
this._isRotation = isRotation(this.direction);
|
|
158
|
+
|
|
159
|
+
// Callbacks
|
|
160
|
+
this.onAfterUpdate = null; // (drive) => void
|
|
161
|
+
|
|
162
|
+
// Apply start position immediately
|
|
163
|
+
this._applyToNode();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get isAtTarget() {
|
|
167
|
+
return Math.abs(this.currentPosition - this.targetPosition) < 0.01;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Move to a specific position */
|
|
171
|
+
moveTo(pos, speed) {
|
|
172
|
+
this.targetPosition = pos;
|
|
173
|
+
if (speed !== undefined) this.targetSpeed = speed;
|
|
174
|
+
this.isRunning = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stop() {
|
|
178
|
+
this.isRunning = false;
|
|
179
|
+
this.currentSpeed = 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Called every frame from DriveEngine tick */
|
|
183
|
+
update(dt) {
|
|
184
|
+
if (this.jogForward || this.jogBackward) {
|
|
185
|
+
// Jog mode: continuous motion (conveyor-style)
|
|
186
|
+
this.currentSpeed = this.targetSpeed;
|
|
187
|
+
const dir = this.jogForward ? 1 : -1;
|
|
188
|
+
let next = this.currentPosition + dir * this.currentSpeed * dt;
|
|
189
|
+
if (this.useLimits) {
|
|
190
|
+
next = Math.max(this.lowerLimit, Math.min(this.upperLimit, next));
|
|
191
|
+
}
|
|
192
|
+
this.currentPosition = next;
|
|
193
|
+
this._applyToNode();
|
|
194
|
+
this.onAfterUpdate?.(this);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!this.isRunning) return;
|
|
199
|
+
|
|
200
|
+
const dist = this.targetPosition - this.currentPosition;
|
|
201
|
+
if (Math.abs(dist) < 0.01) {
|
|
202
|
+
this.currentPosition = this.targetPosition;
|
|
203
|
+
this.isRunning = false;
|
|
204
|
+
this.currentSpeed = 0;
|
|
205
|
+
this._applyToNode();
|
|
206
|
+
this.onAfterUpdate?.(this);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const dir = Math.sign(dist);
|
|
211
|
+
const spd = this.targetSpeed;
|
|
212
|
+
|
|
213
|
+
if (this.acceleration > 0) {
|
|
214
|
+
// Kinematic acceleration/deceleration
|
|
215
|
+
const stopDist = (this.currentSpeed * this.currentSpeed) / (2 * this.acceleration);
|
|
216
|
+
if (stopDist >= Math.abs(dist)) {
|
|
217
|
+
this.currentSpeed = Math.max(0, this.currentSpeed - this.acceleration * dt);
|
|
218
|
+
} else if (this.currentSpeed < spd) {
|
|
219
|
+
this.currentSpeed = Math.min(spd, this.currentSpeed + this.acceleration * dt);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
this.currentSpeed = spd;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let next = this.currentPosition + dir * this.currentSpeed * dt;
|
|
226
|
+
// Clamp to target
|
|
227
|
+
if (dir > 0 && next > this.targetPosition) next = this.targetPosition;
|
|
228
|
+
if (dir < 0 && next < this.targetPosition) next = this.targetPosition;
|
|
229
|
+
// Apply limits
|
|
230
|
+
if (this.useLimits) {
|
|
231
|
+
next = Math.max(this.lowerLimit, Math.min(this.upperLimit, next));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.currentPosition = next;
|
|
235
|
+
this._applyToNode();
|
|
236
|
+
this.onAfterUpdate?.(this);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_applyToNode() {
|
|
240
|
+
const pos = this.currentPosition + this.offset;
|
|
241
|
+
const sign = this.reverseDir ? -1 : 1;
|
|
242
|
+
|
|
243
|
+
switch (this.direction) {
|
|
244
|
+
case DriveDirection.LinearX:
|
|
245
|
+
this.node.position.x = this._basePos.x + sign * pos * MM_TO_M;
|
|
246
|
+
break;
|
|
247
|
+
case DriveDirection.LinearY:
|
|
248
|
+
this.node.position.y = this._basePos.y + sign * pos * MM_TO_M;
|
|
249
|
+
break;
|
|
250
|
+
case DriveDirection.LinearZ:
|
|
251
|
+
this.node.position.z = this._basePos.z + sign * pos * MM_TO_M;
|
|
252
|
+
break;
|
|
253
|
+
case DriveDirection.RotationX: {
|
|
254
|
+
const rad = sign * pos * DEG_TO_RAD;
|
|
255
|
+
this.node.quaternion.copy(this._baseQuat);
|
|
256
|
+
this.node.rotateX(rad);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case DriveDirection.RotationY: {
|
|
260
|
+
const rad = sign * pos * DEG_TO_RAD;
|
|
261
|
+
this.node.quaternion.copy(this._baseQuat);
|
|
262
|
+
this.node.rotateY(rad);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case DriveDirection.RotationZ: {
|
|
266
|
+
const rad = sign * pos * DEG_TO_RAD;
|
|
267
|
+
this.node.quaternion.copy(this._baseQuat);
|
|
268
|
+
this.node.rotateZ(rad);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Serialize current config back to JSON */
|
|
275
|
+
toConfig() {
|
|
276
|
+
return {
|
|
277
|
+
direction: this.direction,
|
|
278
|
+
reverseDir: this.reverseDir,
|
|
279
|
+
offset: this.offset,
|
|
280
|
+
startPosition: this.startPosition,
|
|
281
|
+
targetSpeed: this.targetSpeed,
|
|
282
|
+
acceleration: this.acceleration,
|
|
283
|
+
useLimits: this.useLimits,
|
|
284
|
+
lowerLimit: this.lowerLimit,
|
|
285
|
+
upperLimit: this.upperLimit,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── DriveCylinder ─────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* DriveCylinder - pneumatic/hydraulic cylinder automation.
|
|
294
|
+
*
|
|
295
|
+
* Listens to two ioBroker signals (Out/In).
|
|
296
|
+
* Out=true → moves to MaxPos at stroke/TimeOut speed
|
|
297
|
+
* In=true → moves to MinPos at stroke/TimeIn speed
|
|
298
|
+
* Writes feedback signals: IsOut, IsIn, IsMovingOut, IsMovingIn
|
|
299
|
+
*/
|
|
300
|
+
export class DriveCylinder {
|
|
301
|
+
/**
|
|
302
|
+
* @param {Drive} drive - the underlying drive
|
|
303
|
+
* @param {SignalBus} bus - signal bus
|
|
304
|
+
* @param {object} config - cylinder config
|
|
305
|
+
*/
|
|
306
|
+
constructor(drive, bus, config = {}) {
|
|
307
|
+
this.drive = drive;
|
|
308
|
+
this.bus = bus;
|
|
309
|
+
this.name = drive.name;
|
|
310
|
+
|
|
311
|
+
this.minPos = config.minPos ?? 0;
|
|
312
|
+
this.maxPos = config.maxPos ?? 100;
|
|
313
|
+
this.timeOut = config.timeOut ?? 1; // seconds to extend
|
|
314
|
+
this.timeIn = config.timeIn ?? 1; // seconds to retract
|
|
315
|
+
this.oneBit = config.oneBit ?? false;
|
|
316
|
+
this.invertLogic = config.invertLogic ?? false;
|
|
317
|
+
|
|
318
|
+
// ioBroker signal paths
|
|
319
|
+
this.signalOut = config.signalOut ?? null;
|
|
320
|
+
this.signalIn = config.signalIn ?? null;
|
|
321
|
+
this.signalIsOut = config.signalIsOut ?? null;
|
|
322
|
+
this.signalIsIn = config.signalIsIn ?? null;
|
|
323
|
+
this.signalMovingOut = config.signalMovingOut ?? null;
|
|
324
|
+
this.signalMovingIn = config.signalMovingIn ?? null;
|
|
325
|
+
|
|
326
|
+
this._unsubs = [];
|
|
327
|
+
this._init();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_init() {
|
|
331
|
+
const { drive, bus } = this;
|
|
332
|
+
const stroke = Math.abs(this.maxPos - this.minPos);
|
|
333
|
+
|
|
334
|
+
// Set initial position
|
|
335
|
+
drive.currentPosition = this.minPos;
|
|
336
|
+
drive._applyToNode();
|
|
337
|
+
|
|
338
|
+
if (this.signalOut) {
|
|
339
|
+
const unsub = bus.subscribe(this.signalOut, (val) => {
|
|
340
|
+
let v = Boolean(val);
|
|
341
|
+
if (this.invertLogic) v = !v;
|
|
342
|
+
if (this.oneBit) {
|
|
343
|
+
drive.targetSpeed = stroke / (v ? this.timeOut : this.timeIn);
|
|
344
|
+
drive.moveTo(v ? this.maxPos : this.minPos);
|
|
345
|
+
} else if (v) {
|
|
346
|
+
drive.targetSpeed = stroke / this.timeOut;
|
|
347
|
+
drive.moveTo(this.maxPos);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
this._unsubs.push(unsub);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (this.signalIn && !this.oneBit) {
|
|
354
|
+
const unsub = bus.subscribe(this.signalIn, (val) => {
|
|
355
|
+
let v = Boolean(val);
|
|
356
|
+
if (this.invertLogic) v = !v;
|
|
357
|
+
if (v) {
|
|
358
|
+
drive.targetSpeed = stroke / this.timeIn;
|
|
359
|
+
drive.moveTo(this.minPos);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
this._unsubs.push(unsub);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Feedback signals via onAfterUpdate
|
|
366
|
+
if (this.signalIsOut || this.signalIsIn || this.signalMovingOut || this.signalMovingIn) {
|
|
367
|
+
let prevIsOut = false, prevIsIn = true;
|
|
368
|
+
drive.onAfterUpdate = (d) => {
|
|
369
|
+
const atMax = Math.abs(d.currentPosition - this.maxPos) < 0.01;
|
|
370
|
+
const atMin = Math.abs(d.currentPosition - this.minPos) < 0.01;
|
|
371
|
+
const movingOut = d.isRunning && d.targetPosition === this.maxPos;
|
|
372
|
+
const movingIn = d.isRunning && d.targetPosition === this.minPos;
|
|
373
|
+
|
|
374
|
+
if (atMax !== prevIsOut) {
|
|
375
|
+
if (this.signalIsOut) bus.set(this.signalIsOut, atMax);
|
|
376
|
+
if (this.signalIsOut) bus.set(this.signalIsOut + '_max', atMax);
|
|
377
|
+
prevIsOut = atMax;
|
|
378
|
+
}
|
|
379
|
+
if (atMin !== prevIsIn) {
|
|
380
|
+
if (this.signalIsIn) bus.set(this.signalIsIn, atMin);
|
|
381
|
+
prevIsIn = atMin;
|
|
382
|
+
}
|
|
383
|
+
if (this.signalMovingOut) bus.set(this.signalMovingOut, movingOut);
|
|
384
|
+
if (this.signalMovingIn) bus.set(this.signalMovingIn, movingIn);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
dispose() {
|
|
390
|
+
for (const fn of this._unsubs) fn();
|
|
391
|
+
this._unsubs = [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
toConfig() {
|
|
395
|
+
return {
|
|
396
|
+
type: 'cylinder',
|
|
397
|
+
minPos: this.minPos,
|
|
398
|
+
maxPos: this.maxPos,
|
|
399
|
+
timeOut: this.timeOut,
|
|
400
|
+
timeIn: this.timeIn,
|
|
401
|
+
oneBit: this.oneBit,
|
|
402
|
+
invertLogic: this.invertLogic,
|
|
403
|
+
signalOut: this.signalOut,
|
|
404
|
+
signalIn: this.signalIn,
|
|
405
|
+
signalIsOut: this.signalIsOut,
|
|
406
|
+
signalIsIn: this.signalIsIn,
|
|
407
|
+
signalMovingOut: this.signalMovingOut,
|
|
408
|
+
signalMovingIn: this.signalMovingIn,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── DriveSimple ───────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* DriveSimple - continuous jog motion (conveyor belt, rotating drum).
|
|
417
|
+
* Forward=true → jog forward, Backward=true → jog backward
|
|
418
|
+
*/
|
|
419
|
+
export class DriveSimple {
|
|
420
|
+
constructor(drive, bus, config = {}) {
|
|
421
|
+
this.drive = drive;
|
|
422
|
+
this.bus = bus;
|
|
423
|
+
this.signalForward = config.signalForward ?? null;
|
|
424
|
+
this.signalBackward = config.signalBackward ?? null;
|
|
425
|
+
this._unsubs = [];
|
|
426
|
+
this._init();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_init() {
|
|
430
|
+
const { drive, bus } = this;
|
|
431
|
+
if (this.signalForward) {
|
|
432
|
+
const unsub = bus.subscribe(this.signalForward, (v) => {
|
|
433
|
+
drive.jogForward = Boolean(v);
|
|
434
|
+
if (drive.jogForward) drive.jogBackward = false;
|
|
435
|
+
});
|
|
436
|
+
this._unsubs.push(unsub);
|
|
437
|
+
}
|
|
438
|
+
if (this.signalBackward) {
|
|
439
|
+
const unsub = bus.subscribe(this.signalBackward, (v) => {
|
|
440
|
+
drive.jogBackward = Boolean(v);
|
|
441
|
+
if (drive.jogBackward) drive.jogForward = false;
|
|
442
|
+
});
|
|
443
|
+
this._unsubs.push(unsub);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
dispose() {
|
|
448
|
+
for (const fn of this._unsubs) fn();
|
|
449
|
+
this._unsubs = [];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
toConfig() {
|
|
453
|
+
return {
|
|
454
|
+
type: 'simple',
|
|
455
|
+
signalForward: this.signalForward,
|
|
456
|
+
signalBackward: this.signalBackward,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── DriveEngine ───────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* DriveEngine - main animation/kinematics engine.
|
|
465
|
+
*
|
|
466
|
+
* Manages all drives and ticks them on each animation frame.
|
|
467
|
+
* Integrates with the 3D editor's animation loop.
|
|
468
|
+
*
|
|
469
|
+
* Usage:
|
|
470
|
+
* const engine = new DriveEngine();
|
|
471
|
+
* engine.createFromAssets(sceneData.assets, threeObjects);
|
|
472
|
+
* // In animation loop:
|
|
473
|
+
* engine.tick(dt);
|
|
474
|
+
*/
|
|
475
|
+
export class DriveEngine {
|
|
476
|
+
constructor() {
|
|
477
|
+
this.bus = new SignalBus();
|
|
478
|
+
this.drives = []; // Drive[]
|
|
479
|
+
this.controllers = []; // DriveCylinder | DriveSimple
|
|
480
|
+
this._lastTime = null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create drives from sceneData assets.
|
|
485
|
+
* Each asset can have an array `drives` in its config:
|
|
486
|
+
*
|
|
487
|
+
* asset.drives = [{
|
|
488
|
+
* direction: 'LinearY',
|
|
489
|
+
* targetSpeed: 50,
|
|
490
|
+
* type: 'cylinder',
|
|
491
|
+
* minPos: 0, maxPos: 200,
|
|
492
|
+
* timeOut: 1.5, timeIn: 1.0,
|
|
493
|
+
* signalOut: 'mywebui.0.device.cylinder_out',
|
|
494
|
+
* signalIn: 'mywebui.0.device.cylinder_in',
|
|
495
|
+
* signalIsOut: 'mywebui.0.device.cylinder_isout',
|
|
496
|
+
* }]
|
|
497
|
+
*/
|
|
498
|
+
createFromAssets(assets, threeObjects) {
|
|
499
|
+
this.dispose();
|
|
500
|
+
|
|
501
|
+
for (const asset of assets) {
|
|
502
|
+
if (!asset.drives || asset.drives.length === 0) continue;
|
|
503
|
+
|
|
504
|
+
// Find matching Three.js object
|
|
505
|
+
const obj = threeObjects.find(o => o.userData.assetData?.id === asset.id);
|
|
506
|
+
if (!obj) continue;
|
|
507
|
+
|
|
508
|
+
for (const driveCfg of asset.drives) {
|
|
509
|
+
const drive = new Drive(obj, driveCfg);
|
|
510
|
+
this.drives.push(drive);
|
|
511
|
+
|
|
512
|
+
if (driveCfg.type === 'cylinder') {
|
|
513
|
+
const ctrl = new DriveCylinder(drive, this.bus, driveCfg);
|
|
514
|
+
this.controllers.push(ctrl);
|
|
515
|
+
} else if (driveCfg.type === 'simple') {
|
|
516
|
+
const ctrl = new DriveSimple(drive, this.bus, driveCfg);
|
|
517
|
+
this.controllers.push(ctrl);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Tick all drives. Call this in the Three.js animation loop.
|
|
525
|
+
* @param {number} dt - delta time in seconds
|
|
526
|
+
*/
|
|
527
|
+
tick(dt) {
|
|
528
|
+
for (const drive of this.drives) {
|
|
529
|
+
drive.update(dt);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Called from Three.js requestAnimationFrame - auto-computes dt */
|
|
534
|
+
tickFromRAF(timestamp) {
|
|
535
|
+
if (this._lastTime === null) { this._lastTime = timestamp; return; }
|
|
536
|
+
const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap at 100ms
|
|
537
|
+
this._lastTime = timestamp;
|
|
538
|
+
this.tick(dt);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Dispose all subscriptions and clear drives */
|
|
542
|
+
dispose() {
|
|
543
|
+
for (const ctrl of this.controllers) ctrl.dispose();
|
|
544
|
+
this.controllers = [];
|
|
545
|
+
this.drives = [];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Full dispose including signal bus */
|
|
549
|
+
destroy() {
|
|
550
|
+
this.dispose();
|
|
551
|
+
this.bus.dispose();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Get drive by asset id and drive index */
|
|
555
|
+
getDrive(assetId, driveIndex = 0) {
|
|
556
|
+
let count = 0;
|
|
557
|
+
for (const drive of this.drives) {
|
|
558
|
+
if (drive.node.userData.assetData?.id === assetId) {
|
|
559
|
+
if (count === driveIndex) return drive;
|
|
560
|
+
count++;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseCustomWebComponentConstructorAppend, css, html } from "@gokturk413/base-custom-webcomponent";
|
|
2
2
|
import { iobrokerHandler } from "../common/IobrokerHandler.js";
|
|
3
|
+
import { DriveEngine } from "./IobrokerWebui3DDriveEngine.js";
|
|
3
4
|
|
|
4
5
|
export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstructorAppend {
|
|
5
6
|
static template = html`
|
|
@@ -168,6 +169,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
168
169
|
this._selectionBox = null;
|
|
169
170
|
this._activeTab = 'all';
|
|
170
171
|
this._collapsedSections = {};
|
|
172
|
+
this.driveEngine = new DriveEngine();
|
|
173
|
+
this._threeObjects = [];
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
async connectedCallback() {
|
|
@@ -242,13 +245,14 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
242
245
|
this.mouse = new THREE.Vector2();
|
|
243
246
|
|
|
244
247
|
let animating = true;
|
|
245
|
-
const animate = () => {
|
|
248
|
+
const animate = (ts) => {
|
|
246
249
|
if (!animating) return;
|
|
247
250
|
requestAnimationFrame(animate);
|
|
251
|
+
if (this.driveEngine) this.driveEngine.tickFromRAF(ts);
|
|
248
252
|
this.controls.update();
|
|
249
253
|
this.renderer.render(this.scene, this.camera);
|
|
250
254
|
};
|
|
251
|
-
animate
|
|
255
|
+
requestAnimationFrame(animate);
|
|
252
256
|
this._stopAnimation = () => { animating = false; };
|
|
253
257
|
|
|
254
258
|
this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
|
|
@@ -327,6 +331,12 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
327
331
|
model.rotation.set(asset.rotation?.x ?? 0, asset.rotation?.y ?? 0, asset.rotation?.z ?? 0);
|
|
328
332
|
model.scale.set(asset.scale?.x ?? 1, asset.scale?.y ?? 1, asset.scale?.z ?? 1);
|
|
329
333
|
this.scene.add(model);
|
|
334
|
+
// Register for DriveEngine
|
|
335
|
+
this._threeObjects = this._threeObjects.filter(o => o.userData.assetId !== asset.id);
|
|
336
|
+
this._threeObjects.push(model);
|
|
337
|
+
if (this.driveEngine && Array.isArray(this.sceneData?.assets)) {
|
|
338
|
+
this.driveEngine.createFromAssets(this.sceneData.assets, this._threeObjects);
|
|
339
|
+
}
|
|
330
340
|
this.refreshTree();
|
|
331
341
|
}, undefined, (err) => {
|
|
332
342
|
if (revoke) URL.revokeObjectURL(url);
|
|
@@ -458,8 +468,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
458
468
|
if (showAssets) {
|
|
459
469
|
const filtered = assets.filter(a => !search || a.name.toLowerCase().includes(search));
|
|
460
470
|
this._renderSection(el, 'Assets', 'assets', filtered, (asset) => {
|
|
461
|
-
const
|
|
462
|
-
|
|
471
|
+
const driveCnt = (asset.drives?.length ?? 0);
|
|
472
|
+
const badge = driveCnt > 0 ? `${driveCnt} drive${driveCnt>1?'s':''}` : 'model';
|
|
473
|
+
const isSelected = this.selectedObject?.userData.assetData?.id === asset.id;
|
|
474
|
+
const node = this._makeTreeNode('📦', asset.name, badge, isSelected, driveCnt > 0 ? 'drive' : '');
|
|
463
475
|
node.addEventListener('click', () => this._selectAsset(asset));
|
|
464
476
|
node.addEventListener('contextmenu', (e) => { e.preventDefault(); this._assetContextMenu(asset, e); });
|
|
465
477
|
return node;
|
|
@@ -622,7 +634,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
622
634
|
rotation: { x: 0, y: 0, z: 0 },
|
|
623
635
|
scale: { x: 1, y: 1, z: 1 },
|
|
624
636
|
visible: true,
|
|
625
|
-
bindings: {}
|
|
637
|
+
bindings: {},
|
|
638
|
+
drives: []
|
|
626
639
|
};
|
|
627
640
|
if (!this.sceneData.assets) this.sceneData.assets = [];
|
|
628
641
|
this.sceneData.assets.push(asset);
|
|
@@ -707,6 +720,7 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
707
720
|
|
|
708
721
|
disconnectedCallback() {
|
|
709
722
|
if (this._stopAnimation) this._stopAnimation();
|
|
723
|
+
if (this.driveEngine) this.driveEngine.destroy();
|
|
710
724
|
if (this.renderer) {
|
|
711
725
|
this.renderer.dispose();
|
|
712
726
|
this.renderer.domElement?.remove();
|
|
@@ -1498,19 +1498,22 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1498
1498
|
}
|
|
1499
1499
|
}
|
|
1500
1500
|
show3DObjectProperties(obj, onChange) {
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
let panel;
|
|
1505
|
-
if (!existing) {
|
|
1501
|
+
// Reuse cached panel reference (avoid querySelector with invalid CSS id)
|
|
1502
|
+
let panel = this._tdPropsPanel;
|
|
1503
|
+
if (!panel || !panel.isConnected) {
|
|
1506
1504
|
panel = document.createElement('div');
|
|
1507
|
-
panel.id =
|
|
1505
|
+
panel.id = 'tdPropsPanel';
|
|
1508
1506
|
panel.title = '3D Properties';
|
|
1509
1507
|
panel.style.cssText = 'width:100%;height:100%;overflow:auto;background:#1e1e1e;color:#ccc;font-size:12px;font-family:"Segoe UI",sans-serif;';
|
|
1510
|
-
|
|
1508
|
+
// Dock to right side (attributeDock area)
|
|
1509
|
+
panel.setAttribute('dock-spawn-dock-to', 'attributeDock');
|
|
1510
|
+
panel.setAttribute('dock-spawn-dock-type', 'fill');
|
|
1511
|
+
panel.setAttribute('dock-spawn-panel-type', 'document');
|
|
1512
|
+
this._dock.appendChild(panel);
|
|
1513
|
+
this._tdPropsPanel = panel;
|
|
1511
1514
|
} else {
|
|
1512
|
-
|
|
1513
|
-
|
|
1515
|
+
// Activate existing tab
|
|
1516
|
+
this.isDockOpenAndActivate('tdPropsPanel');
|
|
1514
1517
|
}
|
|
1515
1518
|
if (!panel) return;
|
|
1516
1519
|
|
|
@@ -1520,63 +1523,98 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1520
1523
|
const rot = obj.rotation ?? { x: 0, y: 0, z: 0 };
|
|
1521
1524
|
const scl = obj.scale ?? { x: 1, y: 1, z: 1 };
|
|
1522
1525
|
|
|
1526
|
+
const bindingsHtml = obj.bindings && Object.keys(obj.bindings).length > 0
|
|
1527
|
+
? Object.entries(obj.bindings).map(([k, bv]) =>
|
|
1528
|
+
`<div style="padding:2px 0;border-bottom:1px solid #3c3c3c;display:flex;gap:4px;">
|
|
1529
|
+
<span style="color:#4ec9b0;flex:1;">${k}</span>
|
|
1530
|
+
<span style="color:#888;flex:2;overflow:hidden;text-overflow:ellipsis;">${bv.signal||''}</span>
|
|
1531
|
+
</div>`).join('')
|
|
1532
|
+
: '<span style="font-style:italic;color:#555;">No bindings</span>';
|
|
1533
|
+
|
|
1534
|
+
const drives = Array.isArray(obj.drives) ? obj.drives : [];
|
|
1535
|
+
const drivesHtml = drives.length > 0
|
|
1536
|
+
? drives.map((d, i) => `
|
|
1537
|
+
<div class="drive-card" data-drive-idx="${i}">
|
|
1538
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
|
1539
|
+
<span style="color:#4ec9b0;font-size:10px;font-weight:bold;">Drive ${i+1} · ${d.direction||'LinearX'}</span>
|
|
1540
|
+
<button class="drive-del-btn" data-drive-idx="${i}" style="background:#5a1a1a;color:#f44747;border:1px solid #8a2a2a;border-radius:2px;cursor:pointer;padding:1px 5px;font-size:9px;">✕</button>
|
|
1541
|
+
</div>
|
|
1542
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px;font-size:10px;">
|
|
1543
|
+
<div><span style="color:#888;">Type:</span> <span style="color:#ccc;">${d.type||'cylinder'}</span></div>
|
|
1544
|
+
<div><span style="color:#888;">Speed:</span> <span style="color:#ccc;">${d.targetSpeed||100} mm/s</span></div>
|
|
1545
|
+
${d.type==='cylinder'||!d.type ? `
|
|
1546
|
+
<div><span style="color:#888;">Min:</span> <span style="color:#ccc;">${d.minPos??0} mm</span></div>
|
|
1547
|
+
<div><span style="color:#888;">Max:</span> <span style="color:#ccc;">${d.maxPos??100} mm</span></div>
|
|
1548
|
+
<div style="grid-column:1/-1;"><span style="color:#888;">Out signal:</span><br><span style="color:#9cdcfe;word-break:break-all;">${d.signalOut||'—'}</span></div>
|
|
1549
|
+
<div style="grid-column:1/-1;"><span style="color:#888;">In signal:</span><br><span style="color:#9cdcfe;word-break:break-all;">${d.signalIn||'—'}</span></div>
|
|
1550
|
+
` : `
|
|
1551
|
+
<div style="grid-column:1/-1;"><span style="color:#888;">Forward:</span><br><span style="color:#9cdcfe;word-break:break-all;">${d.signalForward||'—'}</span></div>
|
|
1552
|
+
<div style="grid-column:1/-1;"><span style="color:#888;">Backward:</span><br><span style="color:#9cdcfe;word-break:break-all;">${d.signalBackward||'—'}</span></div>
|
|
1553
|
+
`}
|
|
1554
|
+
</div>
|
|
1555
|
+
</div>`).join('')
|
|
1556
|
+
: '<span style="font-style:italic;color:#555;font-size:10px;">No drives configured</span>';
|
|
1557
|
+
|
|
1523
1558
|
panel.innerHTML = `
|
|
1559
|
+
<style>
|
|
1560
|
+
.pg-group{margin-bottom:10px;}
|
|
1561
|
+
.pg-header{color:#888;font-size:10px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;padding:4px 0;border-bottom:1px solid #3c3c3c;margin-bottom:4px;}
|
|
1562
|
+
.pg-row{display:flex;align-items:center;margin-bottom:3px;}
|
|
1563
|
+
.pg-row label{width:16px;color:#888;font-size:10px;margin-right:6px;}
|
|
1564
|
+
.pg-input{flex:1;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 5px;border-radius:2px;font-size:11px;}
|
|
1565
|
+
.pg-input:focus{outline:none;border-color:#9cdcfe;}
|
|
1566
|
+
.drive-card{background:#1a2a1a;border:1px solid #2a4a2a;border-radius:3px;padding:6px;margin-bottom:6px;}
|
|
1567
|
+
</style>
|
|
1524
1568
|
<div style="padding:8px;border-bottom:1px solid #3c3c3c;background:#252526;">
|
|
1525
|
-
<div style="color:#9cdcfe;font-weight:bold;font-size:11px;letter-spacing:0.5px;"
|
|
1569
|
+
<div style="color:#9cdcfe;font-weight:bold;font-size:11px;letter-spacing:0.5px;">📦 ${obj.name || 'Object'}</div>
|
|
1526
1570
|
<div style="color:#666;font-size:10px;">${obj.type || 'model'}</div>
|
|
1527
1571
|
</div>
|
|
1528
1572
|
<div style="padding:8px;">
|
|
1529
1573
|
<div class="pg-group">
|
|
1530
1574
|
<div class="pg-header">Position</div>
|
|
1531
|
-
<div class="pg-row"><label>X</label><input class="pg-input"
|
|
1532
|
-
<div class="pg-row"><label>Y</label><input class="pg-input"
|
|
1533
|
-
<div class="pg-row"><label>Z</label><input class="pg-input"
|
|
1575
|
+
<div class="pg-row"><label>X</label><input class="pg-input" data-fid="px" type="number" step="0.1" value="${(pos.x||0).toFixed(3)}"></div>
|
|
1576
|
+
<div class="pg-row"><label>Y</label><input class="pg-input" data-fid="py" type="number" step="0.1" value="${(pos.y||0).toFixed(3)}"></div>
|
|
1577
|
+
<div class="pg-row"><label>Z</label><input class="pg-input" data-fid="pz" type="number" step="0.1" value="${(pos.z||0).toFixed(3)}"></div>
|
|
1534
1578
|
</div>
|
|
1535
1579
|
<div class="pg-group">
|
|
1536
1580
|
<div class="pg-header">Rotation (rad)</div>
|
|
1537
|
-
<div class="pg-row"><label>X</label><input class="pg-input"
|
|
1538
|
-
<div class="pg-row"><label>Y</label><input class="pg-input"
|
|
1539
|
-
<div class="pg-row"><label>Z</label><input class="pg-input"
|
|
1581
|
+
<div class="pg-row"><label>X</label><input class="pg-input" data-fid="rx" type="number" step="0.01" value="${(rot.x||0).toFixed(4)}"></div>
|
|
1582
|
+
<div class="pg-row"><label>Y</label><input class="pg-input" data-fid="ry" type="number" step="0.01" value="${(rot.y||0).toFixed(4)}"></div>
|
|
1583
|
+
<div class="pg-row"><label>Z</label><input class="pg-input" data-fid="rz" type="number" step="0.01" value="${(rot.z||0).toFixed(4)}"></div>
|
|
1540
1584
|
</div>
|
|
1541
1585
|
<div class="pg-group">
|
|
1542
1586
|
<div class="pg-header">Scale</div>
|
|
1543
|
-
<div class="pg-row"><label>X</label><input class="pg-input"
|
|
1544
|
-
<div class="pg-row"><label>Y</label><input class="pg-input"
|
|
1545
|
-
<div class="pg-row"><label>Z</label><input class="pg-input"
|
|
1587
|
+
<div class="pg-row"><label>X</label><input class="pg-input" data-fid="sx" type="number" step="0.1" value="${(scl.x||1).toFixed(3)}"></div>
|
|
1588
|
+
<div class="pg-row"><label>Y</label><input class="pg-input" data-fid="sy" type="number" step="0.1" value="${(scl.y||1).toFixed(3)}"></div>
|
|
1589
|
+
<div class="pg-row"><label>Z</label><input class="pg-input" data-fid="sz" type="number" step="0.1" value="${(scl.z||1).toFixed(3)}"></div>
|
|
1590
|
+
</div>
|
|
1591
|
+
<div class="pg-group">
|
|
1592
|
+
<div class="pg-header">⚙ Drives / Kinematics</div>
|
|
1593
|
+
<div class="drives-list" style="margin-bottom:6px;">${drivesHtml}</div>
|
|
1594
|
+
<button class="drive-add-btn" style="width:100%;padding:4px;background:#1a2a3a;color:#9cdcfe;border:1px solid #2a4a6a;border-radius:3px;cursor:pointer;font-size:10px;">+ Add Drive</button>
|
|
1546
1595
|
</div>
|
|
1547
1596
|
<div class="pg-group">
|
|
1548
1597
|
<div class="pg-header">ioBroker Bindings</div>
|
|
1549
|
-
<div
|
|
1550
|
-
|
|
1551
|
-
? Object.entries(obj.bindings).map(([k,v]) =>
|
|
1552
|
-
`<div style="padding:2px 0;border-bottom:1px solid #3c3c3c;display:flex;gap:4px;">
|
|
1553
|
-
<span style="color:#4ec9b0;flex:1;">${k}</span>
|
|
1554
|
-
<span style="color:#888;flex:2;overflow:hidden;text-overflow:ellipsis;">${v.signal||''}</span>
|
|
1555
|
-
</div>`).join('')
|
|
1556
|
-
: '<span style="font-style:italic;">No bindings</span>'
|
|
1557
|
-
}</div>
|
|
1558
|
-
<button id="addBindBtn" style="width:100%;padding:4px;background:#1a3a1a;color:#4ec9b0;border:1px solid #2a5a2a;border-radius:3px;cursor:pointer;font-size:10px;margin-top:4px;">+ Add Binding</button>
|
|
1598
|
+
<div style="font-size:10px;padding:4px 0;">${bindingsHtml}</div>
|
|
1599
|
+
<button class="bind-add-btn" style="width:100%;padding:4px;background:#1a3a1a;color:#4ec9b0;border:1px solid #2a5a2a;border-radius:3px;cursor:pointer;font-size:10px;margin-top:4px;">+ Add Binding</button>
|
|
1559
1600
|
</div>
|
|
1560
1601
|
</div>
|
|
1561
|
-
<style>
|
|
1562
|
-
.pg-group { margin-bottom:10px; }
|
|
1563
|
-
.pg-header { color:#888;font-size:10px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;padding:4px 0;border-bottom:1px solid #3c3c3c;margin-bottom:4px; }
|
|
1564
|
-
.pg-row { display:flex;align-items:center;margin-bottom:3px; }
|
|
1565
|
-
.pg-row label { width:16px;color:#888;font-size:10px;margin-right:6px; }
|
|
1566
|
-
.pg-input { flex:1;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 5px;border-radius:2px;font-size:11px; }
|
|
1567
|
-
.pg-input:focus { outline:none;border-color:#9cdcfe; }
|
|
1568
|
-
</style>
|
|
1569
1602
|
`;
|
|
1570
1603
|
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1604
|
+
const p = panel;
|
|
1605
|
+
const v = (id) => parseFloat(p.querySelector('[data-fid="'+id+'"]')?.value) || 0;
|
|
1606
|
+
const v1 = (id) => parseFloat(p.querySelector('[data-fid="'+id+'"]')?.value) || 1;
|
|
1607
|
+
|
|
1608
|
+
p.querySelectorAll('[data-fid]').forEach(inp => {
|
|
1609
|
+
inp.addEventListener('change', () => {
|
|
1610
|
+
obj.position = { x: v('px'), y: v('py'), z: v('pz') };
|
|
1611
|
+
obj.rotation = { x: v('rx'), y: v('ry'), z: v('rz') };
|
|
1612
|
+
obj.scale = { x: v1('sx'), y: v1('sy'), z: v1('sz') };
|
|
1613
|
+
if (onChange) onChange(obj);
|
|
1614
|
+
});
|
|
1615
|
+
});
|
|
1577
1616
|
|
|
1578
|
-
|
|
1579
|
-
panel.querySelector('#addBindBtn').addEventListener('click', () => {
|
|
1617
|
+
p.querySelector('.bind-add-btn')?.addEventListener('click', () => {
|
|
1580
1618
|
const prop = prompt('Property (e.g. position.x, rotation.y, scale.z):', 'position.x');
|
|
1581
1619
|
if (!prop) return;
|
|
1582
1620
|
const signal = prompt('ioBroker signal path:', '');
|
|
@@ -1585,6 +1623,135 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
|
|
|
1585
1623
|
obj.bindings[prop] = { signal, type: 'number' };
|
|
1586
1624
|
this.show3DObjectProperties(obj, onChange);
|
|
1587
1625
|
});
|
|
1626
|
+
|
|
1627
|
+
p.querySelector('.drive-add-btn')?.addEventListener('click', () => {
|
|
1628
|
+
this._showAddDriveDialog(obj, onChange);
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
p.querySelectorAll('.drive-del-btn').forEach(btn => {
|
|
1632
|
+
btn.addEventListener('click', () => {
|
|
1633
|
+
const idx = parseInt(btn.dataset.driveIdx);
|
|
1634
|
+
if (!Array.isArray(obj.drives)) return;
|
|
1635
|
+
obj.drives.splice(idx, 1);
|
|
1636
|
+
if (onChange) onChange(obj);
|
|
1637
|
+
this.show3DObjectProperties(obj, onChange);
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
_showAddDriveDialog(obj, onChange) {
|
|
1643
|
+
// Build a modal dialog for drive configuration
|
|
1644
|
+
const overlay = document.createElement('div');
|
|
1645
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:99999;display:flex;align-items:center;justify-content:center;';
|
|
1646
|
+
|
|
1647
|
+
const DIRECTIONS = ['LinearX','LinearY','LinearZ','RotationX','RotationY','RotationZ'];
|
|
1648
|
+
const dirOptions = DIRECTIONS.map(d => `<option value="${d}">${d}</option>`).join('');
|
|
1649
|
+
|
|
1650
|
+
overlay.innerHTML = `
|
|
1651
|
+
<div style="background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:16px;width:340px;font-family:'Segoe UI',sans-serif;font-size:12px;color:#ccc;">
|
|
1652
|
+
<div style="color:#9cdcfe;font-weight:bold;font-size:13px;margin-bottom:12px;">⚙ Add Drive</div>
|
|
1653
|
+
<div style="display:grid;gap:8px;">
|
|
1654
|
+
<div>
|
|
1655
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Type</label>
|
|
1656
|
+
<select id="drv_type" style="width:100%;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1657
|
+
<option value="cylinder">Cylinder (Out/In signals)</option>
|
|
1658
|
+
<option value="simple">Simple (Forward/Backward jog)</option>
|
|
1659
|
+
</select>
|
|
1660
|
+
</div>
|
|
1661
|
+
<div>
|
|
1662
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Axis / Direction</label>
|
|
1663
|
+
<select id="drv_dir" style="width:100%;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">${dirOptions}</select>
|
|
1664
|
+
</div>
|
|
1665
|
+
<div>
|
|
1666
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Speed (mm/s or deg/s)</label>
|
|
1667
|
+
<input id="drv_speed" type="number" value="100" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1668
|
+
</div>
|
|
1669
|
+
<div id="cyl_opts">
|
|
1670
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px;">
|
|
1671
|
+
<div>
|
|
1672
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Min Pos (mm)</label>
|
|
1673
|
+
<input id="drv_min" type="number" value="0" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1674
|
+
</div>
|
|
1675
|
+
<div>
|
|
1676
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Max Pos (mm)</label>
|
|
1677
|
+
<input id="drv_max" type="number" value="200" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px;">
|
|
1681
|
+
<div>
|
|
1682
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Time Out (s)</label>
|
|
1683
|
+
<input id="drv_tout" type="number" value="1" step="0.1" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1684
|
+
</div>
|
|
1685
|
+
<div>
|
|
1686
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Time In (s)</label>
|
|
1687
|
+
<input id="drv_tin" type="number" value="1" step="0.1" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1688
|
+
</div>
|
|
1689
|
+
</div>
|
|
1690
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Signal Out (extend)</label>
|
|
1691
|
+
<input id="drv_sigout" type="text" placeholder="mywebui.0.device.out" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#9cdcfe;padding:4px;border-radius:3px;margin-bottom:6px;">
|
|
1692
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Signal In (retract)</label>
|
|
1693
|
+
<input id="drv_sigin" type="text" placeholder="mywebui.0.device.in" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#9cdcfe;padding:4px;border-radius:3px;margin-bottom:6px;">
|
|
1694
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">IsOut feedback (optional)</label>
|
|
1695
|
+
<input id="drv_isout" type="text" placeholder="mywebui.0.device.isout" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:4px;border-radius:3px;">
|
|
1696
|
+
</div>
|
|
1697
|
+
<div id="simple_opts" style="display:none;">
|
|
1698
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Signal Forward</label>
|
|
1699
|
+
<input id="drv_fwd" type="text" placeholder="mywebui.0.device.forward" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#9cdcfe;padding:4px;border-radius:3px;margin-bottom:6px;">
|
|
1700
|
+
<label style="color:#888;font-size:10px;display:block;margin-bottom:2px;">Signal Backward</label>
|
|
1701
|
+
<input id="drv_bwd" type="text" placeholder="mywebui.0.device.backward" style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#9cdcfe;padding:4px;border-radius:3px;">
|
|
1702
|
+
</div>
|
|
1703
|
+
</div>
|
|
1704
|
+
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
|
|
1705
|
+
<button id="drv_cancel" style="padding:5px 14px;background:#3c3c3c;color:#ccc;border:1px solid #555;border-radius:3px;cursor:pointer;">Cancel</button>
|
|
1706
|
+
<button id="drv_ok" style="padding:5px 14px;background:#0e639c;color:#fff;border:1px solid #1177bb;border-radius:3px;cursor:pointer;">Add Drive</button>
|
|
1707
|
+
</div>
|
|
1708
|
+
</div>
|
|
1709
|
+
`;
|
|
1710
|
+
|
|
1711
|
+
document.body.appendChild(overlay);
|
|
1712
|
+
|
|
1713
|
+
const g = (id) => overlay.querySelector('#' + id);
|
|
1714
|
+
g('drv_type').addEventListener('change', () => {
|
|
1715
|
+
const t = g('drv_type').value;
|
|
1716
|
+
g('cyl_opts').style.display = t === 'cylinder' ? '' : 'none';
|
|
1717
|
+
g('simple_opts').style.display = t === 'simple' ? '' : 'none';
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
g('drv_cancel').addEventListener('click', () => document.body.removeChild(overlay));
|
|
1721
|
+
|
|
1722
|
+
g('drv_ok').addEventListener('click', () => {
|
|
1723
|
+
const type = g('drv_type').value;
|
|
1724
|
+
const direction = g('drv_dir').value;
|
|
1725
|
+
const targetSpeed = parseFloat(g('drv_speed').value) || 100;
|
|
1726
|
+
if (!Array.isArray(obj.drives)) obj.drives = [];
|
|
1727
|
+
|
|
1728
|
+
if (type === 'cylinder') {
|
|
1729
|
+
obj.drives.push({
|
|
1730
|
+
type: 'cylinder',
|
|
1731
|
+
direction,
|
|
1732
|
+
targetSpeed,
|
|
1733
|
+
minPos: parseFloat(g('drv_min').value) || 0,
|
|
1734
|
+
maxPos: parseFloat(g('drv_max').value) || 200,
|
|
1735
|
+
timeOut: parseFloat(g('drv_tout').value) || 1,
|
|
1736
|
+
timeIn: parseFloat(g('drv_tin').value) || 1,
|
|
1737
|
+
signalOut: g('drv_sigout').value.trim() || null,
|
|
1738
|
+
signalIn: g('drv_sigin').value.trim() || null,
|
|
1739
|
+
signalIsOut: g('drv_isout').value.trim() || null,
|
|
1740
|
+
});
|
|
1741
|
+
} else {
|
|
1742
|
+
obj.drives.push({
|
|
1743
|
+
type: 'simple',
|
|
1744
|
+
direction,
|
|
1745
|
+
targetSpeed,
|
|
1746
|
+
useLimits: false,
|
|
1747
|
+
signalForward: g('drv_fwd').value.trim() || null,
|
|
1748
|
+
signalBackward: g('drv_bwd').value.trim() || null,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
document.body.removeChild(overlay);
|
|
1752
|
+
if (onChange) onChange(obj);
|
|
1753
|
+
this.show3DObjectProperties(obj, onChange);
|
|
1754
|
+
});
|
|
1588
1755
|
}
|
|
1589
1756
|
async open3DScreenEditor(name) {
|
|
1590
1757
|
let id = '3dscreen_' + name;
|