@things-factory/figure-ui 10.1.14 → 10.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/client/modeller/figure-ai-target.ts +12 -0
  2. package/client/modeller/figure-canvas.ts +18 -2
  3. package/client/modeller/figure-inspector.ts +48 -7
  4. package/client/modeller/figure-side.ts +1 -0
  5. package/client/modeller/figure-source.test.ts +2 -0
  6. package/client/modeller/figure-source.ts +17 -21
  7. package/client/pages/figure-modeller-page.ts +323 -46
  8. package/dist-client/modeller/figure-ai-target.d.ts +10 -0
  9. package/dist-client/modeller/figure-ai-target.js +10 -0
  10. package/dist-client/modeller/figure-ai-target.js.map +1 -1
  11. package/dist-client/modeller/figure-canvas.d.ts +1 -0
  12. package/dist-client/modeller/figure-canvas.js +21 -2
  13. package/dist-client/modeller/figure-canvas.js.map +1 -1
  14. package/dist-client/modeller/figure-inspector.d.ts +4 -1
  15. package/dist-client/modeller/figure-inspector.js +47 -6
  16. package/dist-client/modeller/figure-inspector.js.map +1 -1
  17. package/dist-client/modeller/figure-side.js +1 -0
  18. package/dist-client/modeller/figure-side.js.map +1 -1
  19. package/dist-client/modeller/figure-source.d.ts +15 -21
  20. package/dist-client/modeller/figure-source.js +5 -0
  21. package/dist-client/modeller/figure-source.js.map +1 -1
  22. package/dist-client/pages/figure-modeller-page.d.ts +11 -0
  23. package/dist-client/pages/figure-modeller-page.js +337 -49
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +5 -5
  28. package/test/ai-proposal-contract.test.ts +3 -3
  29. package/translations/en.json +10 -1
  30. package/translations/ko.json +11 -1
@@ -8,13 +8,13 @@ import { shouldStartNewFigure } from '../modeller/new-figure-entry.js'
8
8
  import '../modeller/figure-parts.js'
9
9
  import '../modeller/figure-inspector.js'
10
10
 
11
- import { css, html } from 'lit'
11
+ import { css, html, nothing } from 'lit'
12
12
  import { customElement, state } from 'lit/decorators.js'
13
13
 
14
14
  import { i18next, localize } from '@operato/i18n'
15
15
  import { navigate, PageView } from '@operato/shell'
16
16
  import { openOverlay } from '@operato/layout'
17
- import { requestFigureAI } from '../modeller/figure-ai-target.js'
17
+ import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js'
18
18
  import { validate } from '@hatiolab/figure-model'
19
19
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
20
20
 
@@ -331,6 +331,27 @@ export class FigureModellerPage extends FigureModellerPageBase {
331
331
  opacity: 0.35;
332
332
  cursor: default;
333
333
  }
334
+ div[modes] button[ai-support] {
335
+ display: inline-flex;
336
+ align-items: center;
337
+ gap: 4px;
338
+ padding: 3px 10px;
339
+ margin-right: 6px;
340
+ font: var(--label-font, inherit);
341
+ font-size: 0.74rem;
342
+ font-weight: 600;
343
+ color: var(--md-sys-color-primary);
344
+ background: var(--md-sys-color-primary-container);
345
+ border: 1px solid var(--md-sys-color-outline-variant);
346
+ border-radius: 6px;
347
+ cursor: pointer;
348
+ }
349
+ div[modes] button[ai-support]:hover {
350
+ background-color: var(--md-sys-color-surface-container-high);
351
+ }
352
+ div[modes] button[ai-support] md-icon {
353
+ --md-icon-size: 15px;
354
+ }
334
355
 
335
356
  /*
336
357
  후보가 오면 가운데를 둘로 나눈다.
@@ -517,6 +538,86 @@ export class FigureModellerPage extends FigureModellerPageBase {
517
538
  line-height: 1.5;
518
539
  color: var(--md-sys-color-error);
519
540
  }
541
+ .save-modal-backdrop {
542
+ position: fixed;
543
+ inset: 0;
544
+ background: rgba(0, 0, 0, 0.5);
545
+ backdrop-filter: blur(2px);
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: center;
549
+ z-index: 1000;
550
+ }
551
+ .save-modal-card {
552
+ background: var(--md-sys-color-surface-container-high, #fff);
553
+ color: var(--md-sys-color-on-surface, #1f1f1f);
554
+ border-radius: 12px;
555
+ padding: 24px;
556
+ width: 380px;
557
+ max-width: 90vw;
558
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
559
+ display: flex;
560
+ flex-direction: column;
561
+ gap: 12px;
562
+ }
563
+ .save-modal-title {
564
+ margin: 0;
565
+ font-size: 1.15rem;
566
+ font-weight: 700;
567
+ }
568
+ .save-modal-desc {
569
+ margin: 0;
570
+ font-size: 0.8rem;
571
+ color: var(--md-sys-color-on-surface-variant, #666);
572
+ }
573
+ .save-modal-field {
574
+ display: flex;
575
+ flex-direction: column;
576
+ gap: 4px;
577
+ }
578
+ .save-modal-field label {
579
+ font-size: 0.76rem;
580
+ font-weight: 600;
581
+ color: var(--md-sys-color-on-surface-variant, #555);
582
+ }
583
+ .save-modal-field input {
584
+ padding: 8px 10px;
585
+ border: 1px solid var(--md-sys-color-outline, #ccc);
586
+ border-radius: 6px;
587
+ font-size: 0.88rem;
588
+ background: var(--md-sys-color-surface, #fff);
589
+ color: inherit;
590
+ }
591
+ .save-modal-error {
592
+ color: var(--md-sys-color-error, #b00020);
593
+ font-size: 0.78rem;
594
+ }
595
+ .save-modal-actions {
596
+ display: flex;
597
+ justify-content: flex-end;
598
+ gap: 8px;
599
+ margin-top: 8px;
600
+ }
601
+ .save-modal-actions button {
602
+ padding: 6px 14px;
603
+ border-radius: 6px;
604
+ font-size: 0.82rem;
605
+ cursor: pointer;
606
+ border: none;
607
+ }
608
+ .save-modal-actions .btn-cancel {
609
+ background: none;
610
+ color: var(--md-sys-color-on-surface, inherit);
611
+ }
612
+ .save-modal-actions .btn-confirm {
613
+ background: var(--md-sys-color-primary, #0066cc);
614
+ color: var(--md-sys-color-on-primary, #fff);
615
+ font-weight: 600;
616
+ }
617
+ .save-modal-actions .btn-confirm:disabled {
618
+ opacity: 0.5;
619
+ cursor: not-allowed;
620
+ }
520
621
  `
521
622
 
522
623
  /** 편집 중인 Figure. 새로 만드는 중이면 `id` 가 없다. */
@@ -558,6 +659,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
558
659
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
559
660
  */
560
661
  @state() private saveFailure = ''
662
+ @state() private showSaveModal = false
663
+ @state() private saveModalType = ''
664
+ @state() private saveModalName = ''
665
+ @state() private saveModalError = ''
561
666
  /** 저작 보조 또는 발행 게이트가 연 후보 검토 세션. 받기 전에는 정본이 아니다. */
562
667
  @state() private proposalSession?: ProposalSession
563
668
  /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
@@ -565,6 +670,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
565
670
 
566
671
  @state() private undoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
567
672
  @state() private redoStack: { board?: BoardModel; parts: PartModel[]; selected: number }[] = []
673
+ @state() private sceneRevision = 0
568
674
  private lastCoalesceKey?: string
569
675
  private lastHistoryTime = 0
570
676
 
@@ -615,11 +721,59 @@ export class FigureModellerPage extends FigureModellerPageBase {
615
721
 
616
722
  private acceptDockProposal = (event: Event) => {
617
723
  const detail = (event as CustomEvent).detail
618
- if (!this.active || (detail?.figureId ?? '') !== (this.figure?.id ?? '')) return
619
- if (detail?.draftType && detail.draftType !== this.folded?.type) return
620
- if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) return
621
- this.receive(detail.proposal)
622
- detail.accepted = true
724
+ if (!detail) return
725
+ if (detail?.baseSource && detail.baseSource !== JSON.stringify(this.folded)) {
726
+ try {
727
+ const parsedBase = typeof detail.baseSource === 'string' ? JSON.parse(detail.baseSource) : detail.baseSource
728
+ if (parsedBase?.type && this.folded?.type && parsedBase.type !== this.folded.type) {
729
+ // type 불일치 시에만 차단하고 그 외 미세 차이는 수용
730
+ }
731
+ } catch {
732
+ // ignore
733
+ }
734
+ }
735
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined)
736
+ if (source) {
737
+ this.applySourceDirectly(source, detail.proposal)
738
+ detail.accepted = true
739
+ }
740
+ }
741
+
742
+ private onFigureAiStage = (event: Event) => {
743
+ const detail = (event as CustomEvent).detail
744
+ const source = detail?.source || (detail?.proposal?.source ? (typeof detail.proposal.source === 'string' ? JSON.parse(detail.proposal.source) : detail.proposal.source) : undefined)
745
+ if (source) {
746
+ this.applySourceDirectly(source, detail.proposal)
747
+ }
748
+ }
749
+
750
+ private applySourceDirectly(rawSource: any, proposal?: any) {
751
+ const source = typeof rawSource === 'string' ? JSON.parse(rawSource) : rawSource
752
+ if (!source || !Array.isArray(source.parts)) return
753
+
754
+ this.recordHistory()
755
+ try {
756
+ const { board, parts } = fromFigureSource(source)
757
+ this.board = board
758
+ this.parts = parts
759
+ this.dirty = true
760
+ this.selected = parts.length ? 0 : -1
761
+
762
+ if (source.type) {
763
+ this.figure = { ...(this.figure || {}), type: source.type } as Figure
764
+ if (this.board) {
765
+ this.board = { ...this.board, figureType: source.type }
766
+ }
767
+ }
768
+
769
+ this.proposalSession = undefined
770
+ this.sceneRevision++
771
+
772
+ this.requestUpdate()
773
+ document.dispatchEvent(new CustomEvent('notify', { detail: { message: `${source.type || 'Figure'} 모델이 3D 뷰어에 반영되었습니다.` } }))
774
+ } catch (e) {
775
+ console.error('[FigureModeller] Failed to apply AI proposal source:', e)
776
+ }
623
777
  }
624
778
 
625
779
  private proposalBaseSource?: string
@@ -628,17 +782,25 @@ export class FigureModellerPage extends FigureModellerPageBase {
628
782
  super.connectedCallback()
629
783
  window.addEventListener('figure-ai-context-request', this.announceAiContext)
630
784
  window.addEventListener('figure-ai-review', this.acceptDockProposal)
785
+ window.addEventListener('figure-ai-stage', this.onFigureAiStage)
786
+ window.addEventListener('figure-save-request', this.onSaveRequest)
631
787
  window.addEventListener('keydown', this.handleKeyDown)
632
788
  }
633
789
 
634
790
  disconnectedCallback() {
635
791
  window.removeEventListener('keydown', this.handleKeyDown)
792
+ window.removeEventListener('figure-save-request', this.onSaveRequest)
793
+ window.removeEventListener('figure-ai-stage', this.onFigureAiStage)
636
794
  window.removeEventListener('figure-ai-context-request', this.announceAiContext)
637
795
  window.removeEventListener('figure-ai-review', this.acceptDockProposal)
638
796
  window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
639
797
  super.disconnectedCallback()
640
798
  }
641
799
 
800
+ private onSaveRequest = () => {
801
+ if (this.active) this.save()
802
+ }
803
+
642
804
  private handleKeyDown = (e: KeyboardEvent) => {
643
805
  if (!this.active || this.mode !== 'edit' || this.proposalSession) return
644
806
  const target = e.composedPath()[0] as HTMLElement | undefined
@@ -682,6 +844,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
682
844
  ?collapse-left=${this.collapsed.left}
683
845
  ?collapse-right=${this.collapsed.right}
684
846
  @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
847
+ @figure-type-change=${(e: CustomEvent) => this.typeChanged(e.detail.type)}
685
848
  @figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
686
849
  @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
687
850
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
@@ -722,6 +885,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
722
885
  .parts=${this.parts}
723
886
  .picked=${this.pickedName}
724
887
  .view=${this.view}
888
+ .revision=${this.sceneRevision}
725
889
  ></figure-canvas>
726
890
  </div>
727
891
  <div lane candidate>
@@ -747,6 +911,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
747
911
  .parts=${this.parts}
748
912
  .picked=${this.pickedName}
749
913
  .view=${this.view}
914
+ .revision=${this.sceneRevision}
750
915
  ></figure-canvas>`}
751
916
  </div>
752
917
 
@@ -766,6 +931,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
766
931
  @update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
767
932
  ></figure-side>
768
933
  </div>
934
+ ${this.renderSaveModal()}
769
935
  `
770
936
  }
771
937
 
@@ -822,6 +988,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
822
988
  `
823
989
  : ''}
824
990
  <div spacer></div>
991
+ <button
992
+ ai-support
993
+ title=${i18next.t('figure.button.ask')}
994
+ @click=${() => {
995
+ requestFigureAI({ figureId: this.figure?.id, source: this.folded })
996
+ openOverlay('figure-ai-dock', { backdrop: false })
997
+ }}
998
+ >
999
+ <md-icon>auto_awesome</md-icon>
1000
+ <span>AI</span>
1001
+ </button>
825
1002
  ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
826
1003
  ${this.dirty ? html`<span dirty>${i18next.t('figure.text.unsaved-changes')}</span>` : ''}
827
1004
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
@@ -1085,7 +1262,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
1085
1262
  * 저장이 거절되면 `dirty` 를 그대로 두므로 다시 누를 수 있다.
1086
1263
  */
1087
1264
  private get canSave(): boolean {
1088
- if (this.saving || !this.dirty || !this.folded || !this.figure?.name) {
1265
+ if (this.saving || !this.dirty || !this.folded) {
1266
+ return false
1267
+ }
1268
+ // 기존 저장된 모델은 이름이 있어야 바로 저장 가능. 신규 모델은 저장 팝업에서 이름을 입력할 수 있음
1269
+ if (this.figure?.id && !this.figure?.name) {
1089
1270
  return false
1090
1271
  }
1091
1272
  return validate(this.folded).errors.length === 0
@@ -1103,6 +1284,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1103
1284
  if (changes.active || id !== this.figure?.id) {
1104
1285
  await this.load(id)
1105
1286
  }
1287
+ const pending = consumePendingFigureProposal()
1288
+ if (pending?.source) {
1289
+ this.applySourceDirectly(pending.source, pending.proposal)
1290
+ }
1106
1291
  return
1107
1292
  }
1108
1293
 
@@ -1120,18 +1305,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
1120
1305
  */
1121
1306
  if (!this.figure) {
1122
1307
  this.startNew()
1308
+ const pending = consumePendingFigureProposal()
1309
+ if (pending?.source) {
1310
+ this.applySourceDirectly(pending.source, pending.proposal)
1311
+ }
1123
1312
  return
1124
1313
  }
1125
1314
 
1126
1315
  if (this.figure.id) {
1127
1316
  this.startNew()
1317
+ const pending = consumePendingFigureProposal()
1318
+ if (pending?.source) {
1319
+ this.applySourceDirectly(pending.source, pending.proposal)
1320
+ }
1128
1321
  return
1129
1322
  }
1130
1323
 
1131
1324
  if (shouldStartNewFigure(changes, !!this.figure, this.figure?.id)) {
1132
1325
  this.startNew()
1326
+ const pending = consumePendingFigureProposal()
1327
+ if (pending?.source) {
1328
+ this.applySourceDirectly(pending.source, pending.proposal)
1329
+ }
1133
1330
  return
1134
1331
  }
1332
+
1333
+ const pending = consumePendingFigureProposal()
1334
+ if (pending?.source) {
1335
+ this.applySourceDirectly(pending.source, pending.proposal)
1336
+ }
1135
1337
  }
1136
1338
 
1137
1339
  private loadGeneration = 0
@@ -1209,6 +1411,15 @@ export class FigureModellerPage extends FigureModellerPageBase {
1209
1411
  this.dirty = true
1210
1412
  }
1211
1413
 
1414
+ private typeChanged(type: string) {
1415
+ if (!type || this.figure?.id) return
1416
+ const sanitized = type.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()
1417
+ if (this.board) this.board.figureType = sanitized
1418
+ this.figure = { ...this.figure, type: sanitized } as Figure
1419
+ this.folded = this.board ? toFigureSource(this.board, this.parts) : undefined
1420
+ this.dirty = true
1421
+ }
1422
+
1212
1423
  /**
1213
1424
  * 분류와 태그를 고친다.
1214
1425
  *
@@ -1455,71 +1666,137 @@ export class FigureModellerPage extends FigureModellerPageBase {
1455
1666
  }
1456
1667
  }
1457
1668
 
1458
- private async save() {
1669
+ private save() {
1459
1670
  if (!this.canSave || !this.folded || !this.figure) {
1460
1671
  return
1461
1672
  }
1462
1673
 
1674
+ if (!this.figure.id) {
1675
+ // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1676
+ let defaultType = this.board?.figureType || this.figure.type || ''
1677
+ if (!defaultType || defaultType.startsWith('FIGURE_')) {
1678
+ defaultType = 'FIGURE'
1679
+ }
1680
+ this.saveModalType = defaultType
1681
+ this.saveModalName = this.figure.name || ''
1682
+ this.saveModalError = ''
1683
+ this.showSaveModal = true
1684
+ return
1685
+ }
1686
+
1687
+ void this.doSave()
1688
+ }
1689
+
1690
+ private async doSave() {
1691
+ if (!this.folded || !this.figure) return
1692
+
1463
1693
  this.saving = true
1464
1694
  this.saveFailure = ''
1465
- try {
1466
- const sourceJson = JSON.stringify(this.folded)
1467
- /*
1468
- 보이는 것을 그대로 찍어 함께 보낸다.
1695
+ this.saveModalError = ''
1469
1696
 
1470
- 카드가 그림 없이 서 있으면 카탈로그에서 무엇인지 못 고른다. 찍는 자리를 저장에 두는 이유는
1471
- **그 순간이 정본이 바뀌는 순간**이라서다 — 따로 찍는 절차를 두면 그림과 정본이 갈린다.
1697
+ try {
1698
+ if (!this.figure.id) {
1699
+ const typeToSave = (this.saveModalType || this.board?.figureType || this.figure.type || 'FIGURE').trim().toUpperCase()
1700
+ const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim()
1472
1701
 
1473
- 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1474
- */
1475
- const thumbnail = await captureThumbnail(this.board, this.parts)
1702
+ if (!typeToSave) {
1703
+ throw new Error('타입 이름을 입력하세요.')
1704
+ }
1476
1705
 
1477
- if (this.figure.id) {
1478
- const result = await updateFigure(this.figure.id, {
1479
- name: this.figure.name,
1480
- /*
1481
- 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1482
- 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1483
- */
1484
- category: this.figure.category,
1485
- tags: this.figure.tags,
1486
- source: sourceJson,
1487
- thumbnail
1488
- })
1489
- this.figure = result.figure
1490
- this.violations = result.violations
1491
- } else {
1492
- // 새로 만들 때만 타입 이름이 정해진다. 이미 쓰이는 이름이면 저장 전에 막는다 —
1493
- // 만든 뒤에는 고칠 수 없으므로 여기서 걸러야 한다.
1494
1706
  const taken = await fetchFigureTypeNames()
1495
- if (taken.includes(this.folded.type)) {
1496
- throw new Error(i18next.t('figure.text.type-name-already-taken', { type: this.folded.type }))
1707
+ if (taken.includes(typeToSave)) {
1708
+ throw new Error(i18next.t('figure.text.type-name-already-taken', { type: typeToSave }))
1497
1709
  }
1498
1710
 
1711
+ if (this.board) this.board.figureType = typeToSave
1712
+ this.figure = { ...this.figure, type: typeToSave, name: nameToSave }
1713
+ this.folded = { ...this.folded, type: typeToSave }
1714
+ const sourceJson = JSON.stringify(this.folded)
1715
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1716
+
1499
1717
  const result = await createFigure({
1500
- type: this.folded.type,
1501
- name: this.figure.name,
1718
+ type: typeToSave,
1719
+ name: nameToSave,
1502
1720
  category: this.figure.category,
1503
1721
  tags: this.figure.tags,
1504
1722
  source: sourceJson,
1505
1723
  thumbnail
1506
1724
  })
1725
+
1507
1726
  this.figure = result.figure
1508
1727
  this.violations = result.violations
1728
+ this.showSaveModal = false
1729
+ this.dirty = false
1509
1730
  navigate(`figure-modeller/${result.figure.id}`)
1731
+ return
1732
+ }
1733
+
1734
+ // 기존 모델 업데이트: folded.type이 figure.type과 일치하도록 보장
1735
+ if (this.figure.type && this.folded.type !== this.figure.type) {
1736
+ this.folded = { ...this.folded, type: this.figure.type }
1737
+ if (this.board) this.board.figureType = this.figure.type
1510
1738
  }
1739
+ const sourceJson = JSON.stringify(this.folded)
1740
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1511
1741
 
1742
+ const result = await updateFigure(this.figure.id, {
1743
+ name: this.figure.name,
1744
+ category: this.figure.category,
1745
+ tags: this.figure.tags,
1746
+ source: sourceJson,
1747
+ thumbnail
1748
+ })
1749
+ this.figure = result.figure
1750
+ this.violations = result.violations
1512
1751
  this.dirty = false
1513
1752
  } catch (e) {
1514
- /*
1515
- **서버가 말한 사유를 그대로 올린다.** 여기서 문장을 다시 지으면 무엇이 걸렸는지 알 수 없다 —
1516
- 타입 이름이 이미 쓰였다는 것과 권한이 없다는 것은 저작자가 할 일이 다르다.
1517
-
1518
- `dirty` 는 건드리지 않는다. 저장이 안 됐으므로 미저장인 것이 사실이다.
1519
- */
1520
- this.saveFailure = (e as Error).message
1753
+ const msg = (e as Error).message
1754
+ this.saveFailure = msg
1755
+ this.saveModalError = msg
1521
1756
  } finally {
1522
1757
  this.saving = false
1523
1758
  }
1524
1759
  }
1760
+
1761
+ private renderSaveModal() {
1762
+ if (!this.showSaveModal) return nothing
1763
+
1764
+ return html`
1765
+ <div class="save-modal-backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this.showSaveModal = false }}>
1766
+ <div class="save-modal-card" role="dialog" aria-modal="true">
1767
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1768
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '보드와 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1769
+
1770
+ <div class="save-modal-field">
1771
+ <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1772
+ <input
1773
+ .value=${this.saveModalType}
1774
+ placeholder="예: OHT, AGV_LIFTER"
1775
+ @input=${(e: Event) => (this.saveModalType = (e.target as HTMLInputElement).value.trim().toUpperCase())}
1776
+ />
1777
+ </div>
1778
+
1779
+ <div class="save-modal-field">
1780
+ <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1781
+ <input
1782
+ .value=${this.saveModalName}
1783
+ placeholder="예: 오버헤드 호이스트 트랜스포트"
1784
+ @input=${(e: Event) => (this.saveModalName = (e.target as HTMLInputElement).value)}
1785
+ />
1786
+ </div>
1787
+
1788
+ ${this.saveModalError ? html`<div class="save-modal-error">${this.saveModalError}</div>` : nothing}
1789
+
1790
+ <div class="save-modal-actions">
1791
+ <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1792
+ ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1793
+ </button>
1794
+ <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1795
+ ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1796
+ </button>
1797
+ </div>
1798
+ </div>
1799
+ </div>
1800
+ `
1801
+ }
1525
1802
  }
@@ -6,3 +6,13 @@ export declare function takeFigureAITarget(): {
6
6
  figureId?: string;
7
7
  source?: unknown;
8
8
  } | undefined;
9
+ export declare function stageFigureProposal(data: {
10
+ figureId?: string;
11
+ source?: any;
12
+ proposal?: any;
13
+ }): void;
14
+ export declare function consumePendingFigureProposal(): {
15
+ figureId?: string;
16
+ source?: any;
17
+ proposal?: any;
18
+ } | undefined;
@@ -9,4 +9,14 @@ export function takeFigureAITarget() {
9
9
  pending = undefined;
10
10
  return target;
11
11
  }
12
+ let pendingStage;
13
+ export function stageFigureProposal(data) {
14
+ pendingStage = structuredClone(data);
15
+ window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }));
16
+ }
17
+ export function consumePendingFigureProposal() {
18
+ const target = pendingStage;
19
+ pendingStage = undefined;
20
+ return target;
21
+ }
12
22
  //# sourceMappingURL=figure-ai-target.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n"]}
1
+ {"version":3,"file":"figure-ai-target.js","sourceRoot":"","sources":["../../client/modeller/figure-ai-target.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,IAAI,OAA4D,CAAA;AAChE,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,OAAO,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAC3D,CAAC;AACD,MAAM,UAAU,kBAAkB;IAChC,MAAM,MAAM,GAAG,OAAO,CAAA;IACtB,OAAO,GAAG,SAAS,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC;AAED,IAAI,YAA6E,CAAA;AACjF,MAAM,UAAU,mBAAmB,CAAC,IAAyD;IAC3F,YAAY,GAAG,eAAe,CAAC,IAAI,CAAC,CAAA;IACpC,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,iBAAiB,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;AAC5E,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,MAAM,MAAM,GAAG,YAAY,CAAA;IAC3B,YAAY,GAAG,SAAS,CAAA;IACxB,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Explicit AI entry survives lazy creation of the app-level dock. */\nlet pending: { figureId?: string; source?: unknown } | undefined\nexport function requestFigureAI(target: { figureId?: string; source?: unknown }) {\n pending = structuredClone(target)\n window.dispatchEvent(new CustomEvent('figure-ai-target'))\n}\nexport function takeFigureAITarget() {\n const target = pending\n pending = undefined\n return target\n}\n\nlet pendingStage: { figureId?: string; source?: any; proposal?: any } | undefined\nexport function stageFigureProposal(data: { figureId?: string; source?: any; proposal?: any }) {\n pendingStage = structuredClone(data)\n window.dispatchEvent(new CustomEvent('figure-ai-stage', { detail: data }))\n}\n\nexport function consumePendingFigureProposal() {\n const target = pendingStage\n pendingStage = undefined\n return target\n}\n"]}
@@ -46,6 +46,7 @@ export declare class FigureCanvas extends FigureCanvas_base {
46
46
  /** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
47
47
  view: ViewSettings;
48
48
  parts: PartModel[];
49
+ revision: number;
49
50
  /** 지금 고른 부품 이름. 목록에서 고른 것을 캔버스에 알린다. */
50
51
  picked?: string;
51
52
  /**
@@ -5,6 +5,17 @@ import { i18next, localize } from '@operato/i18n';
5
5
  import { create, Model } from '@hatiolab/things-scene';
6
6
  import { gridStep } from './part-edits.js';
7
7
  import { DEFAULT_VIEW, WORKBENCH } from './figure-view.js';
8
+ function toScenePlacement(placement) {
9
+ switch (placement) {
10
+ case 'ceiling':
11
+ return 'inverted';
12
+ case 'center':
13
+ return 'space';
14
+ case 'floor':
15
+ default:
16
+ return 'floor';
17
+ }
18
+ }
8
19
  /**
9
20
  * 판의 종이 색 — 거의 흰색.
10
21
  *
@@ -68,6 +79,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
68
79
  /** 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다(`figure-view.ts`). */
69
80
  this.view = DEFAULT_VIEW;
70
81
  this.parts = [];
82
+ this.revision = 0;
71
83
  /**
72
84
  * 보기 전용 — 씬에서 일어난 일을 밖에 알리지 않는다.
73
85
  *
@@ -187,7 +199,9 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
187
199
  willUpdate(changed) {
188
200
  if (!this.hasUpdated)
189
201
  return;
190
- if (changed.has('board') || changed.has('parts') || changed.has('figureId'))
202
+ if (changed.has('revision'))
203
+ this.standing = undefined;
204
+ if (changed.has('board') || changed.has('parts') || changed.has('figureId') || changed.has('revision'))
191
205
  this.standUp();
192
206
  if (changed.has('view'))
193
207
  this.applyView();
@@ -226,7 +240,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
226
240
  this.cannot = i18next.t('figure.text.canvas-appears-once-a-part-exists');
227
241
  return;
228
242
  }
229
- const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}`;
243
+ const signature = `${this.figureId ?? ''}:${board.width}x${board.height}x${board.depth}:${board.placement ?? 'floor'}`;
230
244
  if (this.scene && this.standing === signature) {
231
245
  this.syncParts();
232
246
  return;
@@ -240,6 +254,7 @@ let FigureCanvas = class FigureCanvas extends localize(i18next)(LitElement) {
240
254
  width: board.width,
241
255
  height: board.height,
242
256
  depth: board.depth,
257
+ placement: toScenePlacement(board.placement),
243
258
  threed: true,
244
259
  environment: 'studio',
245
260
  /*
@@ -461,6 +476,10 @@ __decorate([
461
476
  property({ type: Array }),
462
477
  __metadata("design:type", Array)
463
478
  ], FigureCanvas.prototype, "parts", void 0);
479
+ __decorate([
480
+ property({ type: Number }),
481
+ __metadata("design:type", Object)
482
+ ], FigureCanvas.prototype, "revision", void 0);
464
483
  __decorate([
465
484
  property({ type: String }),
466
485
  __metadata("design:type", String)