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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "mywebui",
4
- "version": "1.42.13",
4
+ "version": "1.42.15",
5
5
  "titleLang": {
6
6
  "en": "mywebui",
7
7
  "de": "mywebui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.mywebui",
3
- "version": "1.42.13",
3
+ "version": "1.42.15",
4
4
  "description": "ioBroker mywebui - Custom edited mywebui by gokturk413 with 3D Editor",
5
5
  "type": "module",
6
6
  "main": "dist/backend/main.js",
@@ -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 node = this._makeTreeNode('📦', asset.name, 'Drive',
462
- this.selectedObject?.userData.assetData?.id === asset.id);
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
- const id = '3d_properties';
1502
- // Reuse existing dock or open new one
1503
- const existing = this.isDockOpenAndActivate(id);
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 = 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
- this.openDock(panel);
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
- panel = this.shadowRoot?.querySelector('#' + id) ||
1513
- document.querySelector('#' + id);
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;">📦 ${obj.name || 'Object'}</div>
1569
+ <div style="color:#9cdcfe;font-weight:bold;font-size:11px;letter-spacing:0.5px;">&#128230; ${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" id="px" type="number" step="0.1" value="${(pos.x||0).toFixed(3)}"></div>
1532
- <div class="pg-row"><label>Y</label><input class="pg-input" id="py" type="number" step="0.1" value="${(pos.y||0).toFixed(3)}"></div>
1533
- <div class="pg-row"><label>Z</label><input class="pg-input" id="pz" type="number" step="0.1" value="${(pos.z||0).toFixed(3)}"></div>
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" id="rx" type="number" step="0.01" value="${(rot.x||0).toFixed(4)}"></div>
1538
- <div class="pg-row"><label>Y</label><input class="pg-input" id="ry" type="number" step="0.01" value="${(rot.y||0).toFixed(4)}"></div>
1539
- <div class="pg-row"><label>Z</label><input class="pg-input" id="rz" type="number" step="0.01" value="${(rot.z||0).toFixed(4)}"></div>
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" id="sx" type="number" step="0.1" value="${(scl.x||1).toFixed(3)}"></div>
1544
- <div class="pg-row"><label>Y</label><input class="pg-input" id="sy" type="number" step="0.1" value="${(scl.y||1).toFixed(3)}"></div>
1545
- <div class="pg-row"><label>Z</label><input class="pg-input" id="sz" type="number" step="0.1" value="${(scl.z||1).toFixed(3)}"></div>
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">&#9881; 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;">&#43; Add Drive</button>
1546
1595
  </div>
1547
1596
  <div class="pg-group">
1548
1597
  <div class="pg-header">ioBroker Bindings</div>
1549
- <div id="bindList" style="font-size:10px;color:#666;padding:4px 0;">${
1550
- obj.bindings && Object.keys(obj.bindings).length > 0
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 applyChange = () => {
1572
- obj.position = { x: parseFloat(panel.querySelector('#px').value)||0, y: parseFloat(panel.querySelector('#py').value)||0, z: parseFloat(panel.querySelector('#pz').value)||0 };
1573
- obj.rotation = { x: parseFloat(panel.querySelector('#rx').value)||0, y: parseFloat(panel.querySelector('#ry').value)||0, z: parseFloat(panel.querySelector('#rz').value)||0 };
1574
- obj.scale = { x: parseFloat(panel.querySelector('#sx').value)||1, y: parseFloat(panel.querySelector('#sy').value)||1, z: parseFloat(panel.querySelector('#sz').value)||1 };
1575
- if (onChange) onChange(obj);
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
- panel.querySelectorAll('.pg-input').forEach(inp => inp.addEventListener('change', applyChange));
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;">&#9881; 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;