@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.
@@ -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
- let y = existingCenterY ?? (composerRect.top - Math.round(actualHeight / 2) - BOARD_IMAGE_LANE_UI_GAP);
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