@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.
@@ -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
- 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);
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
+ }