@sequent-org/moodboard 1.4.45 → 1.4.47
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/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +29 -1
- package/src/core/flows/Model3dFlow.js +15 -0
- package/src/core/index.js +4 -2
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +2 -0
- package/src/objects/Model3dScreenshotImageObject.js +8 -0
- package/src/objects/ObjectFactory.js +2 -0
- package/src/services/ai/AiClient.js +130 -2
- package/src/services/ai/Model3dSessionController.js +238 -0
- package/src/ui/ImagePropertiesPanel.js +8 -0
- package/src/ui/chat/ChatComposer.js +50 -3
- package/src/ui/chat/ChatWindow.js +452 -5
- package/src/ui/chat/Model3dBoardSkeleton.js +93 -0
- package/src/ui/chat/Model3dProgressOverlay.js +114 -0
- package/src/ui/chat/icons.js +1 -0
- package/src/ui/handles/HandlesDomRenderer.js +30 -0
- package/src/ui/styles/chat.css +173 -0
- package/src/ui/styles/panels.css +264 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AiClient } from '../../services/ai/AiClient.js';
|
|
2
2
|
import { ChatHistoryStore } from '../../services/ai/ChatHistoryStore.js';
|
|
3
3
|
import { ChatSessionController } from '../../services/ai/ChatSessionController.js';
|
|
4
|
+
import { Model3dSessionController } from '../../services/ai/Model3dSessionController.js';
|
|
4
5
|
import { Events } from '../../core/events/Events.js';
|
|
5
6
|
|
|
6
7
|
import { buildChatDom } from './ChatWindowRenderer.js';
|
|
@@ -9,6 +10,8 @@ import { ChatMessageList } from './ChatMessageList.js';
|
|
|
9
10
|
import { ChatComposer } from './ChatComposer.js';
|
|
10
11
|
import { ChatPillMenu } from './ChatPillMenu.js';
|
|
11
12
|
import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
|
|
13
|
+
import { Model3dProgressOverlay } from './Model3dProgressOverlay.js';
|
|
14
|
+
import { Model3dBoardSkeleton } from './Model3dBoardSkeleton.js';
|
|
12
15
|
import { ICONS, RATIO_ICONS, COUNT_ICONS } from './icons.js';
|
|
13
16
|
|
|
14
17
|
const CONTENT_TYPE_OPTIONS = [
|
|
@@ -23,9 +26,64 @@ const CONTENT_TYPE_OPTIONS = [
|
|
|
23
26
|
label: 'Видео',
|
|
24
27
|
icon: ICONS.video,
|
|
25
28
|
description: 'Создать по тексту или руководствуясь первым и последним кадром'
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: '3d',
|
|
32
|
+
label: '3D-модель',
|
|
33
|
+
icon: ICONS.cube,
|
|
34
|
+
description: 'Создать 3D-модель по выбранному изображению'
|
|
26
35
|
}
|
|
27
36
|
];
|
|
28
37
|
|
|
38
|
+
const FACE_COUNT_OPTIONS_3D = [
|
|
39
|
+
{ id: '1.5m', label: '1.5M' },
|
|
40
|
+
{ id: '1m', label: '1M' },
|
|
41
|
+
{ id: '500k', label: '500k' },
|
|
42
|
+
{ id: '50k', label: '50k' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const FACE_COUNT_MAP_3D = { '1.5m': 1500000, '1m': 1000000, '500k': 500000, '50k': 50000 };
|
|
46
|
+
|
|
47
|
+
const TYPE_3D_OPTIONS = [
|
|
48
|
+
{ id: 'geo_tex', label: 'Геометрия + Текстура' },
|
|
49
|
+
{ id: 'geo', label: 'Только геометрия' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const MODE_3D_OPTIONS = [
|
|
53
|
+
{ id: 'image', label: 'Изображение' },
|
|
54
|
+
{ id: 'multi', label: 'Несколько изображений' },
|
|
55
|
+
{ id: 'text', label: 'Текст' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const TEXTURE_STYLE_OPTIONS_3D = [
|
|
59
|
+
{ id: 'general', label: 'Общий' },
|
|
60
|
+
{ id: 'stone_carving', label: 'Резьба по камню' },
|
|
61
|
+
{ id: 'blue_white_porcelain', label: 'Сине-белый фарфор' },
|
|
62
|
+
{ id: 'chinese_style', label: 'Китайский стиль' },
|
|
63
|
+
{ id: 'cartoon', label: 'Мультфильм' },
|
|
64
|
+
{ id: 'cyberpunk', label: 'Киберпанк' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const TEXTURE_STYLE_SUFFIX_3D = {
|
|
68
|
+
general: '',
|
|
69
|
+
stone_carving: ', stone carving style',
|
|
70
|
+
blue_white_porcelain: ', blue and white porcelain style',
|
|
71
|
+
chinese_style: ', Chinese style',
|
|
72
|
+
cartoon: ', cartoon style',
|
|
73
|
+
cyberpunk: ', cyberpunk style',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const RESULT_FORMAT_OPTIONS_3D = [
|
|
77
|
+
{ id: 'glb', label: 'GLB' },
|
|
78
|
+
{ id: 'obj', label: 'OBJ' },
|
|
79
|
+
{ id: 'fbx', label: 'FBX' },
|
|
80
|
+
{ id: 'stl', label: 'STL' },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ViewType для мультивью: 2-е..8-е вложение (1-е = фронт, без ViewType)
|
|
84
|
+
const VIEW_ORDER_3D = ['left', 'right', 'back', 'top', 'bottom', 'left_front', 'right_front'];
|
|
85
|
+
const VIEW_LABELS_3D = ['Лево', 'Право', 'Зад', 'Верх', 'Низ', 'Лево-перед', 'Право-перед'];
|
|
86
|
+
|
|
29
87
|
/** Порядок: портрет (строка 1), альбом (строка 2), авто — крайнее правое */
|
|
30
88
|
const FORMAT_OPTIONS = [
|
|
31
89
|
{ id: '1:1', label: '1:1', icon: RATIO_ICONS['1:1'] },
|
|
@@ -144,6 +202,28 @@ export class ChatWindow {
|
|
|
144
202
|
this._countMenu = null;
|
|
145
203
|
this._unsubscribe = null;
|
|
146
204
|
this._attached = false;
|
|
205
|
+
this._3dFaceCountId = '1m';
|
|
206
|
+
this._3dTypeId = 'geo_tex';
|
|
207
|
+
this._3dFaceCountMenu = null;
|
|
208
|
+
this._3dTypeMenu = null;
|
|
209
|
+
this._3dFaceCountWrapper = null;
|
|
210
|
+
this._3dTypeWrapper = null;
|
|
211
|
+
this._3dMode = 'image';
|
|
212
|
+
this._3dTextureStyleId = 'general';
|
|
213
|
+
this._3dResultFormatId = 'glb';
|
|
214
|
+
this._3dModeMenu = null;
|
|
215
|
+
this._3dModeWrapper = null;
|
|
216
|
+
this._3dTextureStyleMenu = null;
|
|
217
|
+
this._3dTextureStyleWrapper = null;
|
|
218
|
+
this._3dResultFormatMenu = null;
|
|
219
|
+
this._3dResultFormatWrapper = null;
|
|
220
|
+
this._model3dSession = null;
|
|
221
|
+
this._model3dUnsubscribe = null;
|
|
222
|
+
this._model3dProgressOverlay = null;
|
|
223
|
+
this._model3dSkeleton = null;
|
|
224
|
+
// Мировые координаты центра скелетона — модель приземлится ровно сюда,
|
|
225
|
+
// независимо от пана/зума во время генерации.
|
|
226
|
+
this._model3dSkeletonWorld = null;
|
|
147
227
|
this._boardImageMessageIds = new Set();
|
|
148
228
|
this._shiftedForImageBatchKeys = new Set();
|
|
149
229
|
this._boardImageShiftHistory = new Map();
|
|
@@ -181,6 +261,9 @@ export class ChatWindow {
|
|
|
181
261
|
},
|
|
182
262
|
{
|
|
183
263
|
onSubmit: (text, attachments) => {
|
|
264
|
+
if (this._contentTypeId === '3d') {
|
|
265
|
+
return this._submit3d(text, attachments);
|
|
266
|
+
}
|
|
184
267
|
this._clearBoardSelection();
|
|
185
268
|
return this._session.send(text, { ...this._getImageRequestOptions(), referenceImages: attachments });
|
|
186
269
|
},
|
|
@@ -213,6 +296,8 @@ export class ChatWindow {
|
|
|
213
296
|
onSelect: (id) => {
|
|
214
297
|
this._contentTypeId = id;
|
|
215
298
|
this._contentTypeMenu.refresh();
|
|
299
|
+
this._update3dPillVisibility();
|
|
300
|
+
this._update3dSendGate();
|
|
216
301
|
}
|
|
217
302
|
}
|
|
218
303
|
);
|
|
@@ -261,6 +346,9 @@ export class ChatWindow {
|
|
|
261
346
|
);
|
|
262
347
|
this._countMenu.attach();
|
|
263
348
|
|
|
349
|
+
this._attach3dPills();
|
|
350
|
+
this._update3dPillVisibility();
|
|
351
|
+
|
|
264
352
|
const initialState = this._session.getState();
|
|
265
353
|
this._markExistingBoardImages(initialState.messages);
|
|
266
354
|
this._reserveCurrentAiImageLaneSlots();
|
|
@@ -286,6 +374,32 @@ export class ChatWindow {
|
|
|
286
374
|
this._pendingBatchOffsets.clear();
|
|
287
375
|
this._aiImageLaneSlots.clear();
|
|
288
376
|
this._draggingAiImageIds.clear();
|
|
377
|
+
this._model3dUnsubscribe?.();
|
|
378
|
+
this._model3dUnsubscribe = null;
|
|
379
|
+
this._model3dSession?.abort?.();
|
|
380
|
+
this._model3dProgressOverlay?.destroy();
|
|
381
|
+
this._model3dProgressOverlay = null;
|
|
382
|
+
this._clear3dSkeleton();
|
|
383
|
+
this._3dFaceCountMenu?.destroy();
|
|
384
|
+
this._3dFaceCountMenu = null;
|
|
385
|
+
this._3dTypeMenu?.destroy();
|
|
386
|
+
this._3dTypeMenu = null;
|
|
387
|
+
this._3dFaceCountWrapper?.remove();
|
|
388
|
+
this._3dFaceCountWrapper = null;
|
|
389
|
+
this._3dTypeWrapper?.remove();
|
|
390
|
+
this._3dTypeWrapper = null;
|
|
391
|
+
this._3dModeMenu?.destroy();
|
|
392
|
+
this._3dModeMenu = null;
|
|
393
|
+
this._3dModeWrapper?.remove();
|
|
394
|
+
this._3dModeWrapper = null;
|
|
395
|
+
this._3dTextureStyleMenu?.destroy();
|
|
396
|
+
this._3dTextureStyleMenu = null;
|
|
397
|
+
this._3dTextureStyleWrapper?.remove();
|
|
398
|
+
this._3dTextureStyleWrapper = null;
|
|
399
|
+
this._3dResultFormatMenu?.destroy();
|
|
400
|
+
this._3dResultFormatMenu = null;
|
|
401
|
+
this._3dResultFormatWrapper?.remove();
|
|
402
|
+
this._3dResultFormatWrapper = null;
|
|
289
403
|
this._composer?.destroy();
|
|
290
404
|
this._extendedPromptModal?.destroy();
|
|
291
405
|
this._contentTypeMenu?.destroy();
|
|
@@ -310,6 +424,329 @@ export class ChatWindow {
|
|
|
310
424
|
this.detach();
|
|
311
425
|
}
|
|
312
426
|
|
|
427
|
+
_attach3dPills() {
|
|
428
|
+
const pillsContainer = this._refs.formatPill?.closest('.moodboard-chat__pills');
|
|
429
|
+
if (!pillsContainer) return;
|
|
430
|
+
|
|
431
|
+
const mk3dPill = (label, icon) => {
|
|
432
|
+
const wrapper = document.createElement('div');
|
|
433
|
+
wrapper.className = 'moodboard-chat__pill-wrapper';
|
|
434
|
+
const pill = document.createElement('button');
|
|
435
|
+
pill.type = 'button';
|
|
436
|
+
pill.className = 'moodboard-chat__pill';
|
|
437
|
+
pill.setAttribute('aria-haspopup', 'menu');
|
|
438
|
+
pill.setAttribute('aria-expanded', 'false');
|
|
439
|
+
const iconSpan = document.createElement('span');
|
|
440
|
+
iconSpan.className = 'moodboard-chat__pill-icon-wrap';
|
|
441
|
+
iconSpan.innerHTML = icon;
|
|
442
|
+
const labelEl = document.createElement('span');
|
|
443
|
+
labelEl.className = 'moodboard-chat__pill-label';
|
|
444
|
+
labelEl.textContent = label;
|
|
445
|
+
pill.appendChild(iconSpan);
|
|
446
|
+
pill.appendChild(labelEl);
|
|
447
|
+
const menu = document.createElement('div');
|
|
448
|
+
menu.className = 'moodboard-chat__menu';
|
|
449
|
+
menu.setAttribute('role', 'menu');
|
|
450
|
+
wrapper.appendChild(pill);
|
|
451
|
+
wrapper.appendChild(menu);
|
|
452
|
+
return { wrapper, pill, menu, labelEl, iconEl: iconSpan };
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const fc = mk3dPill('1M', ICONS.sliders);
|
|
456
|
+
pillsContainer.appendChild(fc.wrapper);
|
|
457
|
+
this._3dFaceCountWrapper = fc.wrapper;
|
|
458
|
+
this._3dFaceCountMenu = new ChatPillMenu(
|
|
459
|
+
{ trigger: fc.pill, menu: fc.menu, label: fc.labelEl, icon: fc.iconEl },
|
|
460
|
+
{
|
|
461
|
+
getOptions: () => FACE_COUNT_OPTIONS_3D,
|
|
462
|
+
getActiveId: () => this._3dFaceCountId,
|
|
463
|
+
onSelect: (id) => { this._3dFaceCountId = id; this._3dFaceCountMenu.refresh(); }
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
this._3dFaceCountMenu.attach();
|
|
467
|
+
|
|
468
|
+
const tp = mk3dPill('Геометрия + Текстура', ICONS.cube);
|
|
469
|
+
pillsContainer.appendChild(tp.wrapper);
|
|
470
|
+
this._3dTypeWrapper = tp.wrapper;
|
|
471
|
+
this._3dTypeMenu = new ChatPillMenu(
|
|
472
|
+
{ trigger: tp.pill, menu: tp.menu, label: tp.labelEl, icon: tp.iconEl },
|
|
473
|
+
{
|
|
474
|
+
getOptions: () => TYPE_3D_OPTIONS,
|
|
475
|
+
getActiveId: () => this._3dTypeId,
|
|
476
|
+
onSelect: (id) => { this._3dTypeId = id; this._3dTypeMenu.refresh(); }
|
|
477
|
+
}
|
|
478
|
+
);
|
|
479
|
+
this._3dTypeMenu.attach();
|
|
480
|
+
|
|
481
|
+
const md = mk3dPill('Изображение', ICONS.image);
|
|
482
|
+
pillsContainer.appendChild(md.wrapper);
|
|
483
|
+
this._3dModeWrapper = md.wrapper;
|
|
484
|
+
this._3dModeMenu = new ChatPillMenu(
|
|
485
|
+
{ trigger: md.pill, menu: md.menu, label: md.labelEl, icon: md.iconEl },
|
|
486
|
+
{
|
|
487
|
+
getOptions: () => MODE_3D_OPTIONS,
|
|
488
|
+
getActiveId: () => this._3dMode,
|
|
489
|
+
onSelect: (id) => { this._3dModeMenu.refresh(); this._update3dMode(id); }
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
this._3dModeMenu.attach();
|
|
493
|
+
|
|
494
|
+
const ts = mk3dPill('Общий', ICONS.palette);
|
|
495
|
+
pillsContainer.appendChild(ts.wrapper);
|
|
496
|
+
this._3dTextureStyleWrapper = ts.wrapper;
|
|
497
|
+
this._3dTextureStyleMenu = new ChatPillMenu(
|
|
498
|
+
{ trigger: ts.pill, menu: ts.menu, label: ts.labelEl, icon: ts.iconEl },
|
|
499
|
+
{
|
|
500
|
+
getOptions: () => TEXTURE_STYLE_OPTIONS_3D,
|
|
501
|
+
getActiveId: () => this._3dTextureStyleId,
|
|
502
|
+
onSelect: (id) => { this._3dTextureStyleId = id; this._3dTextureStyleMenu.refresh(); }
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
this._3dTextureStyleMenu.attach();
|
|
506
|
+
|
|
507
|
+
const rf = mk3dPill('GLB', ICONS.sliders);
|
|
508
|
+
pillsContainer.appendChild(rf.wrapper);
|
|
509
|
+
this._3dResultFormatWrapper = rf.wrapper;
|
|
510
|
+
this._3dResultFormatMenu = new ChatPillMenu(
|
|
511
|
+
{ trigger: rf.pill, menu: rf.menu, label: rf.labelEl, icon: rf.iconEl },
|
|
512
|
+
{
|
|
513
|
+
getOptions: () => RESULT_FORMAT_OPTIONS_3D,
|
|
514
|
+
getActiveId: () => this._3dResultFormatId,
|
|
515
|
+
onSelect: (id) => { this._3dResultFormatId = id; this._3dResultFormatMenu.refresh(); }
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
this._3dResultFormatMenu.attach();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_update3dPillVisibility() {
|
|
522
|
+
const is3d = this._contentTypeId === '3d';
|
|
523
|
+
const formatWrapper = this._refs.formatPill?.closest('.moodboard-chat__pill-wrapper');
|
|
524
|
+
const countWrapper = this._refs.countPill?.closest('.moodboard-chat__pill-wrapper');
|
|
525
|
+
const modelWrapper = this._refs.modelPill?.closest('.moodboard-chat__pill-wrapper');
|
|
526
|
+
if (formatWrapper) formatWrapper.style.display = is3d ? 'none' : '';
|
|
527
|
+
if (countWrapper) countWrapper.style.display = is3d ? 'none' : '';
|
|
528
|
+
if (modelWrapper) modelWrapper.style.display = is3d ? 'none' : '';
|
|
529
|
+
if (this._3dFaceCountWrapper) this._3dFaceCountWrapper.style.display = is3d ? '' : 'none';
|
|
530
|
+
if (this._3dTypeWrapper) this._3dTypeWrapper.style.display = is3d ? '' : 'none';
|
|
531
|
+
if (this._3dModeWrapper) this._3dModeWrapper.style.display = is3d ? '' : 'none';
|
|
532
|
+
if (this._3dResultFormatWrapper) this._3dResultFormatWrapper.style.display = is3d ? '' : 'none';
|
|
533
|
+
// Стиль текстуры — только в text-режиме
|
|
534
|
+
if (this._3dTextureStyleWrapper) {
|
|
535
|
+
this._3dTextureStyleWrapper.style.display = (is3d && this._3dMode === 'text') ? '' : 'none';
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_update3dSendGate() {
|
|
540
|
+
if (this._contentTypeId !== '3d') {
|
|
541
|
+
this._composer?.setInputEnabled(true);
|
|
542
|
+
this._composer?.setPlaceholderOverride(null);
|
|
543
|
+
if (this._refs?.textarea) {
|
|
544
|
+
this._refs.textarea.placeholder = 'Опишите то, что хотите сгенерировать';
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const inputEnabled = this._3dMode === 'text';
|
|
549
|
+
this._composer?.setInputEnabled(inputEnabled);
|
|
550
|
+
if (this._3dMode === 'image') {
|
|
551
|
+
this._composer?.setPlaceholderOverride('Описание не нужно — нажмите «Отправить»');
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this._composer?.setPlaceholderOverride(null);
|
|
555
|
+
if (this._refs?.textarea) {
|
|
556
|
+
if (this._3dMode === 'text') {
|
|
557
|
+
this._refs.textarea.placeholder = 'Опишите то, что хотите создать';
|
|
558
|
+
} else {
|
|
559
|
+
this._refs.textarea.placeholder = 'Выберите 2–8 изображений на доске';
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
_update3dMode(id) {
|
|
565
|
+
this._3dMode = id;
|
|
566
|
+
this._update3dPillVisibility();
|
|
567
|
+
this._update3dSendGate();
|
|
568
|
+
if (id === 'multi') {
|
|
569
|
+
this._composer?.setAttachmentLabelProvider((i) =>
|
|
570
|
+
i === 0 ? 'Фронт' : (VIEW_LABELS_3D[i - 1] ?? String(i + 1))
|
|
571
|
+
);
|
|
572
|
+
} else {
|
|
573
|
+
this._composer?.setAttachmentLabelProvider(null);
|
|
574
|
+
}
|
|
575
|
+
if (id === 'text') {
|
|
576
|
+
// В text-режиме вложения с доски недоступны — очищаем
|
|
577
|
+
this._composer?.removeAllBoardAttachments?.();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async _submit3d(text, attachments) {
|
|
582
|
+
const mode = this._3dMode;
|
|
583
|
+
|
|
584
|
+
if (mode === 'text') {
|
|
585
|
+
if (!text) return;
|
|
586
|
+
} else if (mode === 'image') {
|
|
587
|
+
if (!Array.isArray(attachments) || attachments.length !== 1) return;
|
|
588
|
+
} else if (mode === 'multi') {
|
|
589
|
+
if (!Array.isArray(attachments) || attachments.length < 2 || attachments.length > 8) return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (!this._model3dSession) {
|
|
593
|
+
this._model3dSession = new Model3dSessionController({ aiClient: this._aiClient });
|
|
594
|
+
}
|
|
595
|
+
this._model3dUnsubscribe?.();
|
|
596
|
+
|
|
597
|
+
if (!this._model3dProgressOverlay) {
|
|
598
|
+
this._model3dProgressOverlay = new Model3dProgressOverlay();
|
|
599
|
+
}
|
|
600
|
+
this._model3dProgressOverlay.attach(this._refs.composer);
|
|
601
|
+
|
|
602
|
+
this._model3dUnsubscribe = this._model3dSession.subscribe((state) => {
|
|
603
|
+
this._onModel3dState(state);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
this._show3dSkeleton();
|
|
608
|
+
} catch (e) {
|
|
609
|
+
console.warn('[ChatWindow] _show3dSkeleton failed:', e);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const suffix = TEXTURE_STYLE_SUFFIX_3D[this._3dTextureStyleId] ?? '';
|
|
613
|
+
const prompt = mode === 'text' ? (text + suffix) : undefined;
|
|
614
|
+
|
|
615
|
+
void this._model3dSession.start({
|
|
616
|
+
mode,
|
|
617
|
+
prompt,
|
|
618
|
+
image: mode !== 'text' ? attachments[0] : undefined,
|
|
619
|
+
multiViewImages: mode === 'multi'
|
|
620
|
+
? attachments.slice(1).map((file, i) => ({ file, viewType: VIEW_ORDER_3D[i] }))
|
|
621
|
+
: undefined,
|
|
622
|
+
model: '3.1',
|
|
623
|
+
generateType: this._3dTypeId === 'geo' ? 'Geometry' : 'Normal',
|
|
624
|
+
faceCount: FACE_COUNT_MAP_3D[this._3dFaceCountId] ?? 1000000,
|
|
625
|
+
pbr: false,
|
|
626
|
+
downloadFormat: this._3dResultFormatId,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
_onModel3dState(state) {
|
|
631
|
+
this._model3dProgressOverlay?.update(state);
|
|
632
|
+
|
|
633
|
+
if (state.status === 'done' && state.result) {
|
|
634
|
+
this._place3dModelOnBoard(state.result);
|
|
635
|
+
this._clear3dSkeleton();
|
|
636
|
+
// Оверлей убираем чуть позже скелетона, чтобы прогресс не «прыгал» —
|
|
637
|
+
// достаточно синхронного detach после размещения.
|
|
638
|
+
this._model3dProgressOverlay?.detach();
|
|
639
|
+
} else if (state.status === 'error') {
|
|
640
|
+
this._model3dProgressOverlay?.detach();
|
|
641
|
+
this._clear3dSkeleton();
|
|
642
|
+
if (this._refs?.errorBlock) {
|
|
643
|
+
const raw = state.error || 'Ошибка генерации 3D-модели';
|
|
644
|
+
// Убираем технический префикс вида "AiClient.submit3dModel (503): ".
|
|
645
|
+
const clean = raw.replace(/^AiClient\.\w+\s*\(\d+\):\s*/, '');
|
|
646
|
+
this._refs.errorBlock.textContent = clean || 'Ошибка генерации 3D-модели';
|
|
647
|
+
this._refs.errorBlock.classList.add('is-visible');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Экранный прямоугольник скелетона/модели в текущем вьюпорте из мировых координат центра.
|
|
654
|
+
* Размер фиксирован шириной BOARD_IMAGE_WIDTH в world-единицах (квадрат), как у заглушки.
|
|
655
|
+
* @param {{x:number,y:number}} worldCenter
|
|
656
|
+
*/
|
|
657
|
+
_compute3dSkeletonRect(worldCenter) {
|
|
658
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
659
|
+
const s = world?.scale?.x || 1;
|
|
660
|
+
const size = Math.round(BOARD_IMAGE_WIDTH * s);
|
|
661
|
+
const screenX = Math.round(worldCenter.x * s + (world?.x || 0));
|
|
662
|
+
const screenY = Math.round(worldCenter.y * s + (world?.y || 0));
|
|
663
|
+
return {
|
|
664
|
+
left: Math.round(screenX - size / 2),
|
|
665
|
+
top: Math.round(screenY - size / 2),
|
|
666
|
+
width: size,
|
|
667
|
+
height: size,
|
|
668
|
+
radius: Math.max(2, Math.round(12 * s))
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
_show3dSkeleton() {
|
|
673
|
+
const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
|
|
674
|
+
if (!world) return;
|
|
675
|
+
const s = world?.scale?.x || 1;
|
|
676
|
+
|
|
677
|
+
// Центр будущей модели в экранных координатах: над композером (как при размещении).
|
|
678
|
+
const composerRect = this._refs?.composer?.getBoundingClientRect?.();
|
|
679
|
+
const screenCenterX = composerRect
|
|
680
|
+
? Math.round(composerRect.left + composerRect.width / 2)
|
|
681
|
+
: 400;
|
|
682
|
+
const screenCenterY = composerRect
|
|
683
|
+
? Math.round(composerRect.top - 200)
|
|
684
|
+
: 200;
|
|
685
|
+
|
|
686
|
+
// _clear3dSkeleton() обнуляет _model3dSkeletonWorld — присваиваем ПОСЛЕ него.
|
|
687
|
+
this._clear3dSkeleton();
|
|
688
|
+
|
|
689
|
+
this._model3dSkeletonWorld = {
|
|
690
|
+
x: (screenCenterX - (world?.x || 0)) / s,
|
|
691
|
+
y: (screenCenterY - (world?.y || 0)) / s
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
this._model3dSkeleton = new Model3dBoardSkeleton({ iconSvg: ICONS.cube });
|
|
695
|
+
this._model3dSkeleton.attach(this._container ?? document.body);
|
|
696
|
+
this._model3dSkeleton.setRect(this._compute3dSkeletonRect(this._model3dSkeletonWorld), { animate: false });
|
|
697
|
+
this._scheduleAnimationFrame(() => this._model3dSkeleton?.enter());
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
_sync3dSkeletonToViewport({ disableTransition = false } = {}) {
|
|
701
|
+
if (!this._model3dSkeleton?.isAttached() || !this._model3dSkeletonWorld) return;
|
|
702
|
+
this._model3dSkeleton.setRect(
|
|
703
|
+
this._compute3dSkeletonRect(this._model3dSkeletonWorld),
|
|
704
|
+
{ animate: !disableTransition }
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
_clear3dSkeleton() {
|
|
709
|
+
this._model3dSkeleton?.destroy();
|
|
710
|
+
this._model3dSkeleton = null;
|
|
711
|
+
this._model3dSkeletonWorld = null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
_place3dModelOnBoard(result) {
|
|
715
|
+
if (!this._boardCore?.eventBus) return;
|
|
716
|
+
const { previewBase64, mimeType, modelUrl, format } = result;
|
|
717
|
+
const src = previewBase64
|
|
718
|
+
? `data:${mimeType || 'image/jpeg'};base64,${previewBase64}`
|
|
719
|
+
: modelUrl;
|
|
720
|
+
if (!src) return;
|
|
721
|
+
|
|
722
|
+
const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
|
|
723
|
+
const s = world?.scale?.x || 1;
|
|
724
|
+
|
|
725
|
+
// Приоритет — мировая точка скелетона: модель приземляется ровно туда,
|
|
726
|
+
// где пользователь видел заглушку, даже если он панорамировал/зумил во время генерации.
|
|
727
|
+
let x;
|
|
728
|
+
let y;
|
|
729
|
+
if (this._model3dSkeletonWorld) {
|
|
730
|
+
x = Math.round(this._model3dSkeletonWorld.x * s + (world?.x || 0));
|
|
731
|
+
y = Math.round(this._model3dSkeletonWorld.y * s + (world?.y || 0));
|
|
732
|
+
} else {
|
|
733
|
+
const composerRect = this._refs?.composer?.getBoundingClientRect?.();
|
|
734
|
+
x = composerRect ? Math.round(composerRect.left + composerRect.width / 2) : 400;
|
|
735
|
+
y = composerRect ? Math.round(composerRect.top - 200) : 200;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
this._boardCore.eventBus.emit(Events.UI.PasteImageAt, {
|
|
739
|
+
x,
|
|
740
|
+
y,
|
|
741
|
+
src,
|
|
742
|
+
name: 'ai-3d-model',
|
|
743
|
+
skipUpload: true,
|
|
744
|
+
objectType: 'model3d-screenshot-img',
|
|
745
|
+
modelUrl,
|
|
746
|
+
format
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
313
750
|
_clearBoardSelection() {
|
|
314
751
|
if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
|
|
315
752
|
this._boardCore.selectTool.clearSelection();
|
|
@@ -862,9 +1299,11 @@ export class ChatWindow {
|
|
|
862
1299
|
|
|
863
1300
|
const onPanUpdate = () => {
|
|
864
1301
|
this._syncPendingOverlaysToViewport({ disableTransition: true });
|
|
1302
|
+
this._sync3dSkeletonToViewport({ disableTransition: true });
|
|
865
1303
|
};
|
|
866
1304
|
const onViewportChange = () => {
|
|
867
1305
|
this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
|
|
1306
|
+
this._sync3dSkeletonToViewport({ disableTransition: true });
|
|
868
1307
|
// Зум меняет проекцию композера в мир: после него существующее изображение может
|
|
869
1308
|
// оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
|
|
870
1309
|
this._ensureExistingImagesClearOfPending();
|
|
@@ -1006,9 +1445,11 @@ export class ChatWindow {
|
|
|
1006
1445
|
const onSelectionRemove = (data) => {
|
|
1007
1446
|
const objectId = data?.object;
|
|
1008
1447
|
if (objectId) this._composer?.removeAttachmentForObject?.(objectId);
|
|
1448
|
+
this._update3dSendGate();
|
|
1009
1449
|
};
|
|
1010
1450
|
const onSelectionClear = () => {
|
|
1011
1451
|
this._composer?.removeAllBoardAttachments?.();
|
|
1452
|
+
this._update3dSendGate();
|
|
1012
1453
|
};
|
|
1013
1454
|
const onBoxSelectStart = () => {
|
|
1014
1455
|
this._boxSelectActive = true;
|
|
@@ -1139,8 +1580,8 @@ export class ChatWindow {
|
|
|
1139
1580
|
return null;
|
|
1140
1581
|
}
|
|
1141
1582
|
|
|
1142
|
-
_clampImageGroupAnchorY(y, referenceHeight) {
|
|
1143
|
-
const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP;
|
|
1583
|
+
_clampImageGroupAnchorY(y, referenceHeight, reserveY = 0) {
|
|
1584
|
+
const clearance = Math.round(referenceHeight / 2) + BOARD_IMAGE_LANE_UI_GAP + reserveY;
|
|
1144
1585
|
|
|
1145
1586
|
const errorBlock = this._refs?.errorBlock;
|
|
1146
1587
|
if (errorBlock && errorBlock.classList.contains('is-visible')) {
|
|
@@ -1174,10 +1615,16 @@ export class ChatWindow {
|
|
|
1174
1615
|
const existingCenterY = this._getAiImageLaneCenterScreenY();
|
|
1175
1616
|
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
1176
1617
|
const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
|
|
1177
|
-
|
|
1618
|
+
|
|
1619
|
+
let reserveY = 0;
|
|
1620
|
+
if (this._refs?.attachmentsPreview && !this._refs.attachmentsPreview.classList.contains('is-visible')) {
|
|
1621
|
+
reserveY = 74; // Резервируем место под ряд вложений (60px + padding)
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
let y = existingCenterY ?? (composerRect.top - reserveY - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
|
|
1178
1625
|
|
|
1179
1626
|
if (existingCenterY == null) {
|
|
1180
|
-
y = this._clampImageGroupAnchorY(y, actualHeight);
|
|
1627
|
+
y = this._clampImageGroupAnchorY(y, actualHeight, reserveY);
|
|
1181
1628
|
}
|
|
1182
1629
|
|
|
1183
1630
|
return {
|
|
@@ -1762,7 +2209,7 @@ function rectsOverlap(a, b) {
|
|
|
1762
2209
|
|
|
1763
2210
|
function isReferenceImageObject(object) {
|
|
1764
2211
|
return Boolean(object?.id)
|
|
1765
|
-
&& (object.type === 'image' || object.type === 'revit-screenshot-img')
|
|
2212
|
+
&& (object.type === 'image' || object.type === 'revit-screenshot-img' || object.type === 'model3d-screenshot-img')
|
|
1766
2213
|
&& typeof getImageObjectSource(object) === 'string';
|
|
1767
2214
|
}
|
|
1768
2215
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-скелетон 3D-модели на полотне доски.
|
|
3
|
+
*
|
|
4
|
+
* Одна ответственность: показывать анимированную заглушку в том месте,
|
|
5
|
+
* где появится сгенерированная 3D-модель. Не знает про сессию и вьюпорт —
|
|
6
|
+
* экранный прямоугольник ему задаёт ChatWindow (он же держит мировые координаты
|
|
7
|
+
* и пересчитывает позицию при pan/zoom).
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle: attach(container) → setRect(rect)* → enter() → detach() → destroy()
|
|
10
|
+
* attach/detach идемпотентны.
|
|
11
|
+
*/
|
|
12
|
+
export class Model3dBoardSkeleton {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {string} [opts.iconSvg] HTML-строка иконки (например ICONS.cube)
|
|
16
|
+
*/
|
|
17
|
+
constructor({ iconSvg = '' } = {}) {
|
|
18
|
+
this._el = null;
|
|
19
|
+
this._iconSvg = iconSvg;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {HTMLElement} container
|
|
24
|
+
*/
|
|
25
|
+
attach(container) {
|
|
26
|
+
if (this._el) return;
|
|
27
|
+
|
|
28
|
+
const el = document.createElement('div');
|
|
29
|
+
el.className = 'moodboard-chat__3d-skeleton moodboard-chat__3d-skeleton--enter';
|
|
30
|
+
|
|
31
|
+
if (this._iconSvg) {
|
|
32
|
+
const icon = document.createElement('span');
|
|
33
|
+
icon.className = 'moodboard-chat__3d-skeleton-icon';
|
|
34
|
+
icon.innerHTML = this._iconSvg;
|
|
35
|
+
el.appendChild(icon);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this._el = el;
|
|
39
|
+
(container ?? document.body).appendChild(el);
|
|
40
|
+
|
|
41
|
+
// Принудительный reflow фиксирует стартовое состояние (--enter) до enter(),
|
|
42
|
+
// иначе браузер смерджит кадры и transition не запустится.
|
|
43
|
+
void el.offsetWidth;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Задаёт экранный прямоугольник (CSS-пиксели вьюпорта, position: fixed).
|
|
48
|
+
* @param {{ left:number, top:number, width:number, height:number, radius?:number }} rect
|
|
49
|
+
* @param {{ animate?: boolean }} [opts]
|
|
50
|
+
*/
|
|
51
|
+
setRect({ left, top, width, height, radius }, { animate = false } = {}) {
|
|
52
|
+
if (!this._el) return;
|
|
53
|
+
const el = this._el;
|
|
54
|
+
|
|
55
|
+
if (!animate) el.style.transition = 'none';
|
|
56
|
+
|
|
57
|
+
el.style.left = `${Math.round(left)}px`;
|
|
58
|
+
el.style.top = `${Math.round(top)}px`;
|
|
59
|
+
el.style.width = `${Math.round(width)}px`;
|
|
60
|
+
el.style.height = `${Math.round(height)}px`;
|
|
61
|
+
if (typeof radius === 'number') {
|
|
62
|
+
el.style.borderRadius = `${Math.max(2, Math.round(radius))}px`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!animate) {
|
|
66
|
+
void el.offsetWidth;
|
|
67
|
+
el.style.removeProperty('transition');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Запускает анимацию появления. */
|
|
72
|
+
enter() {
|
|
73
|
+
if (!this._el) return;
|
|
74
|
+
this._el.classList.remove('moodboard-chat__3d-skeleton--enter');
|
|
75
|
+
this._el.classList.add('moodboard-chat__3d-skeleton--entered');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @returns {boolean} */
|
|
79
|
+
isAttached() {
|
|
80
|
+
return !!this._el;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
detach() {
|
|
84
|
+
if (this._el && this._el.parentNode) {
|
|
85
|
+
this._el.parentNode.removeChild(this._el);
|
|
86
|
+
}
|
|
87
|
+
this._el = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
destroy() {
|
|
91
|
+
this.detach();
|
|
92
|
+
}
|
|
93
|
+
}
|