@sequent-org/moodboard 1.4.44 → 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 +483 -8
- 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 +178 -3
- 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();
|
|
@@ -504,7 +941,7 @@ export class ChatWindow {
|
|
|
504
941
|
worldY: existing.worldY
|
|
505
942
|
};
|
|
506
943
|
this._reserveAiImageLaneSlotForMessage(message.id, existing.worldX, existing.worldY);
|
|
507
|
-
this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance });
|
|
944
|
+
this._applyPendingOverlayScreenLayout(existing.el, screenLayout, { animate: true, enterDistance, scale: s });
|
|
508
945
|
return;
|
|
509
946
|
}
|
|
510
947
|
|
|
@@ -514,6 +951,16 @@ export class ChatWindow {
|
|
|
514
951
|
const overlay = document.createElement('div');
|
|
515
952
|
overlay.className = 'moodboard-chat__pending-overlay moodboard-chat__pending-overlay--enter';
|
|
516
953
|
overlay.style.cssText = `left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px`;
|
|
954
|
+
|
|
955
|
+
overlay.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
|
|
956
|
+
|
|
957
|
+
const fontSize = Math.round(20 * s);
|
|
958
|
+
const labelLeft = 12 * Math.min(1, s);
|
|
959
|
+
const labelBottom = 10 * Math.min(1, s);
|
|
960
|
+
overlay.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
|
|
961
|
+
overlay.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
|
|
962
|
+
overlay.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
|
|
963
|
+
|
|
517
964
|
overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
518
965
|
overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
|
|
519
966
|
|
|
@@ -695,7 +1142,7 @@ export class ChatWindow {
|
|
|
695
1142
|
record.worldX = layout.worldX;
|
|
696
1143
|
record.worldY = layout.worldY;
|
|
697
1144
|
this._reserveAiImageLaneSlotForMessage(messageId, layout.worldX, layout.worldY);
|
|
698
|
-
this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true });
|
|
1145
|
+
this._applyPendingOverlayScreenLayout(record.el, layout, { animate: true, scale });
|
|
699
1146
|
existingOverlaysShifted = true;
|
|
700
1147
|
}
|
|
701
1148
|
|
|
@@ -777,11 +1224,20 @@ export class ChatWindow {
|
|
|
777
1224
|
};
|
|
778
1225
|
}
|
|
779
1226
|
|
|
780
|
-
_applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance } = {}) {
|
|
1227
|
+
_applyPendingOverlayScreenLayout(el, layout, { animate = false, enterDistance, scale = 1 } = {}) {
|
|
781
1228
|
el.style.left = `${layout.left}px`;
|
|
782
1229
|
el.style.top = `${layout.top}px`;
|
|
783
1230
|
el.style.width = `${layout.width}px`;
|
|
784
1231
|
el.style.height = `${layout.height}px`;
|
|
1232
|
+
el.style.borderRadius = `${Math.max(2, Math.round(12 * scale))}px`;
|
|
1233
|
+
|
|
1234
|
+
const fontSize = Math.round(20 * scale);
|
|
1235
|
+
const labelLeft = 12 * Math.min(1, scale);
|
|
1236
|
+
const labelBottom = 10 * Math.min(1, scale);
|
|
1237
|
+
el.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
|
|
1238
|
+
el.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
|
|
1239
|
+
el.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
|
|
1240
|
+
|
|
785
1241
|
if (!animate) return;
|
|
786
1242
|
|
|
787
1243
|
el.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
|
|
@@ -821,6 +1277,15 @@ export class ChatWindow {
|
|
|
821
1277
|
el.style.top = `${Math.round(screenY - hScreen / 2)}px`;
|
|
822
1278
|
el.style.width = `${wScreen}px`;
|
|
823
1279
|
el.style.height = `${hScreen}px`;
|
|
1280
|
+
el.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
|
|
1281
|
+
|
|
1282
|
+
const fontSize = Math.round(20 * s);
|
|
1283
|
+
const labelLeft = 12 * Math.min(1, s);
|
|
1284
|
+
const labelBottom = 10 * Math.min(1, s);
|
|
1285
|
+
el.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
|
|
1286
|
+
el.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
|
|
1287
|
+
el.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
|
|
1288
|
+
|
|
824
1289
|
if (disableTransition) {
|
|
825
1290
|
void el.offsetWidth;
|
|
826
1291
|
el.style.removeProperty('transition');
|
|
@@ -834,9 +1299,11 @@ export class ChatWindow {
|
|
|
834
1299
|
|
|
835
1300
|
const onPanUpdate = () => {
|
|
836
1301
|
this._syncPendingOverlaysToViewport({ disableTransition: true });
|
|
1302
|
+
this._sync3dSkeletonToViewport({ disableTransition: true });
|
|
837
1303
|
};
|
|
838
1304
|
const onViewportChange = () => {
|
|
839
1305
|
this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
|
|
1306
|
+
this._sync3dSkeletonToViewport({ disableTransition: true });
|
|
840
1307
|
// Зум меняет проекцию композера в мир: после него существующее изображение может
|
|
841
1308
|
// оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
|
|
842
1309
|
this._ensureExistingImagesClearOfPending();
|
|
@@ -978,9 +1445,11 @@ export class ChatWindow {
|
|
|
978
1445
|
const onSelectionRemove = (data) => {
|
|
979
1446
|
const objectId = data?.object;
|
|
980
1447
|
if (objectId) this._composer?.removeAttachmentForObject?.(objectId);
|
|
1448
|
+
this._update3dSendGate();
|
|
981
1449
|
};
|
|
982
1450
|
const onSelectionClear = () => {
|
|
983
1451
|
this._composer?.removeAllBoardAttachments?.();
|
|
1452
|
+
this._update3dSendGate();
|
|
984
1453
|
};
|
|
985
1454
|
const onBoxSelectStart = () => {
|
|
986
1455
|
this._boxSelectActive = true;
|
|
@@ -1111,8 +1580,8 @@ export class ChatWindow {
|
|
|
1111
1580
|
return null;
|
|
1112
1581
|
}
|
|
1113
1582
|
|
|
1114
|
-
_clampImageGroupAnchorY(y, referenceHeight) {
|
|
1115
|
-
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;
|
|
1116
1585
|
|
|
1117
1586
|
const errorBlock = this._refs?.errorBlock;
|
|
1118
1587
|
if (errorBlock && errorBlock.classList.contains('is-visible')) {
|
|
@@ -1146,10 +1615,16 @@ export class ChatWindow {
|
|
|
1146
1615
|
const existingCenterY = this._getAiImageLaneCenterScreenY();
|
|
1147
1616
|
const [wr, hr] = parseFormatRatio(this._formatId);
|
|
1148
1617
|
const actualHeight = Math.round(BOARD_IMAGE_WIDTH / (wr / hr));
|
|
1149
|
-
|
|
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);
|
|
1150
1625
|
|
|
1151
1626
|
if (existingCenterY == null) {
|
|
1152
|
-
y = this._clampImageGroupAnchorY(y, actualHeight);
|
|
1627
|
+
y = this._clampImageGroupAnchorY(y, actualHeight, reserveY);
|
|
1153
1628
|
}
|
|
1154
1629
|
|
|
1155
1630
|
return {
|
|
@@ -1734,7 +2209,7 @@ function rectsOverlap(a, b) {
|
|
|
1734
2209
|
|
|
1735
2210
|
function isReferenceImageObject(object) {
|
|
1736
2211
|
return Boolean(object?.id)
|
|
1737
|
-
&& (object.type === 'image' || object.type === 'revit-screenshot-img')
|
|
2212
|
+
&& (object.type === 'image' || object.type === 'revit-screenshot-img' || object.type === 'model3d-screenshot-img')
|
|
1738
2213
|
&& typeof getImageObjectSource(object) === 'string';
|
|
1739
2214
|
}
|
|
1740
2215
|
|