@things-factory/figure-ui 10.1.6 → 10.1.8

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 (51) hide show
  1. package/client/graphql/index.ts +25 -0
  2. package/client/modeller/figure-animations.ts +769 -0
  3. package/client/modeller/figure-ask.ts +6 -1
  4. package/client/modeller/figure-history-panel.ts +52 -1
  5. package/client/modeller/figure-inspector.ts +520 -120
  6. package/client/modeller/figure-side.ts +65 -17
  7. package/client/modeller/proposal-session.ts +92 -0
  8. package/client/pages/figure-list-page.ts +56 -4
  9. package/client/pages/figure-modeller-page.ts +204 -179
  10. package/client/types.ts +39 -0
  11. package/client/viewparts/figure-thumb.ts +78 -0
  12. package/dist-client/graphql/index.d.ts +3 -1
  13. package/dist-client/graphql/index.js +22 -0
  14. package/dist-client/graphql/index.js.map +1 -1
  15. package/dist-client/modeller/figure-animations.d.ts +96 -0
  16. package/dist-client/modeller/figure-animations.js +708 -0
  17. package/dist-client/modeller/figure-animations.js.map +1 -0
  18. package/dist-client/modeller/figure-ask.d.ts +3 -0
  19. package/dist-client/modeller/figure-ask.js +9 -0
  20. package/dist-client/modeller/figure-ask.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.d.ts +7 -0
  22. package/dist-client/modeller/figure-history-panel.js +50 -0
  23. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  24. package/dist-client/modeller/figure-inspector.d.ts +17 -0
  25. package/dist-client/modeller/figure-inspector.js +506 -107
  26. package/dist-client/modeller/figure-inspector.js.map +1 -1
  27. package/dist-client/modeller/figure-side.d.ts +23 -0
  28. package/dist-client/modeller/figure-side.js +68 -16
  29. package/dist-client/modeller/figure-side.js.map +1 -1
  30. package/dist-client/modeller/proposal-session.d.ts +23 -0
  31. package/dist-client/modeller/proposal-session.js +65 -0
  32. package/dist-client/modeller/proposal-session.js.map +1 -0
  33. package/dist-client/pages/figure-list-page.d.ts +10 -0
  34. package/dist-client/pages/figure-list-page.js +55 -4
  35. package/dist-client/pages/figure-list-page.js.map +1 -1
  36. package/dist-client/pages/figure-modeller-page.d.ts +50 -28
  37. package/dist-client/pages/figure-modeller-page.js +197 -176
  38. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  39. package/dist-client/tsconfig.tsbuildinfo +1 -1
  40. package/dist-client/types.d.ts +44 -0
  41. package/dist-client/types.js.map +1 -1
  42. package/dist-client/viewparts/figure-thumb.js +74 -0
  43. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  44. package/package.json +4 -5
  45. package/test/ai-proposal-contract.test.ts +169 -0
  46. package/test/i18n-prefix-guard.test.ts +42 -0
  47. package/translations/en.json +82 -6
  48. package/translations/ja.json +34 -1
  49. package/translations/ko.json +92 -6
  50. package/translations/ms.json +34 -1
  51. package/translations/zh.json +34 -1
@@ -1,18 +1,6 @@
1
1
  import '@material/web/icon/icon.js'
2
2
  import '../modeller/figure-side.js'
3
3
  import '../modeller/figure-ask.js'
4
- /*
5
- 대화 도크. **`/client` 서브패스로 적으면 안 된다.**
6
-
7
- 그 패키지에 `exports` 맵이 없어서 `/client` 는 소스 디렉터리로 떨어지고, webpack 이 `.ts` 를 직접
8
- 컴파일한다. 그 경로로 나온 데코레이터가 `Reflect.metadata` 를 가드 없이 불러 **브라우저에서만**
9
- 죽는다(빌드 로그는 오류 0건). board-ai 와 plant 가 차례로 밟았다.
10
-
11
- 그리고 더 센 이유가 있다 — `board-ui/dist-client` 의 모델러가 같은 엘리먼트를 **빌드된 경로로**
12
- 등록한다. 다른 경로로 적으면 webpack 이 두 모듈로 보고, 두 곳이 같은 커스텀 엘리먼트 이름을
13
- 등록해 나중에 뜨는 화면이 죽는다. 즉 「어느 경로가 되나」가 아니라 **「모두 같은 경로여야 한다」**다.
14
- */
15
- import '@things-factory/ai-assistant/dist-client/index.js'
16
4
  import '../modeller/figure-canvas.js'
17
5
  import { captureThumbnail } from '../modeller/figure-thumbnail.js'
18
6
  import '../modeller/figure-preview.js'
@@ -25,17 +13,18 @@ import { customElement, state } from 'lit/decorators.js'
25
13
  import { i18next, localize } from '@operato/i18n'
26
14
  import { navigate, PageView } from '@operato/shell'
27
15
  import { validate } from '@hatiolab/figure-model'
28
- import { activePalette } from '@hatiolab/things-scene'
29
16
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
30
17
 
31
18
  import * as edits from '../modeller/part-edits.js'
32
19
  import * as proposals from '../modeller/proposal.js'
20
+ import * as proposalSessions from '../modeller/proposal-session.js'
21
+ import type { ProposalSession } from '../modeller/proposal-session.js'
33
22
  import { toFigureSource, fromFigureSource } from '../modeller/figure-source.js'
34
23
  import type { BoardModel, PartModel, PlaneAxis } from '../modeller/figure-source.js'
35
24
  import { DEFAULT_VIEW } from '../modeller/figure-view.js'
36
25
  import type { ViewSettings } from '../modeller/figure-view.js'
37
26
  import { createFigure, fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
38
- import type { Figure, FigureFinding } from '../types.js'
27
+ import type { Figure, FigureFinding, FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
39
28
 
40
29
  const FigureModellerPageBase = localize(i18next)(PageView) as typeof PageView
41
30
 
@@ -147,50 +136,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
147
136
  div[edits] {
148
137
  flex: 1;
149
138
  display: grid;
150
- grid-template-columns: 220px 1fr 320px 0;
139
+ grid-template-columns: 220px 1fr 320px;
151
140
  min-height: 0;
152
141
  overflow: hidden;
153
142
  }
154
- /*
155
- 대화 도크는 **불렀을 때만 자리를 받는다.** 저작의 주인공은 캔버스이고, 대화는 필요할 때
156
- 여는 것이다. 늘 열어 두면 넷으로 나뉜 화면에서 캔버스가 가장 좁아진다.
157
- */
158
- div[edits][dock] {
159
- grid-template-columns: 220px 1fr 320px 360px;
160
- }
161
- div[edits] > ox-board-ai-chat {
162
- grid-column: 4;
163
- min-width: 0;
164
- border-left: 1px solid var(--md-sys-color-outline-variant);
165
- background-color: var(--md-sys-color-surface-container-lowest);
166
- }
167
- div[edits]:not([dock]) > ox-board-ai-chat {
168
- display: none;
169
- }
170
-
171
- /* 도크를 여는 손잡이. 머리줄의 저장 단추 옆에 둔다 */
172
- button[dock] {
173
- flex: none;
174
- display: flex;
175
- align-items: center;
176
- gap: 4px;
177
- padding: 5px 10px;
178
- font: var(--label-font, inherit);
179
- font-size: 0.78rem;
180
- color: var(--md-sys-color-on-surface-variant);
181
- background: none;
182
- border: 1px solid var(--md-sys-color-outline-variant);
183
- border-radius: 6px;
184
- cursor: pointer;
185
- }
186
- button[dock][on] {
187
- color: var(--md-sys-color-primary);
188
- border-color: var(--md-sys-color-primary);
189
- background-color: var(--md-sys-color-surface-container);
190
- }
191
- button[dock] md-icon {
192
- font-size: 16px;
193
- }
194
143
 
195
144
  /*
196
145
  **세 칸의 자리를 못 박는다.**
@@ -489,6 +438,39 @@ export class FigureModellerPage extends FigureModellerPageBase {
489
438
  cursor: default;
490
439
  }
491
440
 
441
+ /* AI의 평가는 판정문이 아니라 수락 전에 읽을 근거다. 숨기지 않고 변경 칩 위에 둔다. */
442
+ div[quality-review] {
443
+ padding: 7px var(--spacing-large, 12px);
444
+ background-color: var(--md-sys-color-primary-container);
445
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
446
+ color: var(--md-sys-color-on-primary-container);
447
+ font-size: 0.72rem;
448
+ line-height: 1.45;
449
+ }
450
+ div[quality-review][review] {
451
+ background-color: var(--md-sys-color-tertiary-container);
452
+ color: var(--md-sys-color-on-tertiary-container);
453
+ }
454
+ div[quality-review] strong { display: block; font-size: 0.74rem; }
455
+ div[quality-review] ul { margin: 4px 0 0; padding-left: 18px; }
456
+ div[proposal-note] {
457
+ padding: 6px var(--spacing-large, 12px) 8px;
458
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
459
+ background-color: var(--md-sys-color-surface-container-low);
460
+ }
461
+ div[proposal-note] label { display: block; font-size: 0.71rem; color: var(--md-sys-color-on-surface-variant); }
462
+ div[proposal-note] input {
463
+ box-sizing: border-box;
464
+ width: 100%;
465
+ margin-top: 4px;
466
+ padding: 5px 7px;
467
+ border: 1px solid var(--md-sys-color-outline-variant);
468
+ border-radius: 5px;
469
+ color: var(--md-sys-color-on-surface);
470
+ background: var(--md-sys-color-surface);
471
+ font: inherit;
472
+ }
473
+
492
474
  /*
493
475
  무엇을 바꾸자는 것인가 — 칩 하나가 변경 하나다.
494
476
 
@@ -584,14 +566,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
584
566
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
585
567
  */
586
568
  @state() private saveFailure = ''
587
- /** 대화 도크가 열려 있나. 저작면의 보기 상태라 저장하지 않는다. */
588
- @state() private dockOpen = false
589
- /** 저작 보조가 후보. 받기 전에는 원본 데이터가 아니다. */
590
- @state() private proposal?: FigureSource
591
- /** 그 후보의 셈. 무엇을 받는 것인지 숫자로도 보여 준다. */
592
- @state() private proposalOf?: { grade: string; triangles: number; groups: number; attempts: number }
593
- /** 받기로 고른 변경들. 처음에는 전부 골라 둔다. */
594
- @state() private picked = new Set<string>()
569
+ /** 저작 보조 또는 발행 게이트가 후보 검토 세션. 받기 전에는 정본이 아니다. */
570
+ @state() private proposalSession?: ProposalSession
571
+ /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
572
+ @state() private proposalFeedback: ProposalFeedback[] = []
595
573
 
596
574
  /**
597
575
  * 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
@@ -607,6 +585,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
607
585
  }
608
586
  }
609
587
 
588
+ /**
589
+ * 페이지 밖 AI 도크에도 **지금 손에 든 정본**을 알려 준다.
590
+ *
591
+ * 모델러 안의 `figure-ask` 는 이미 `folded`를 직접 받는다. 반면 asidebar 도크는 주소의 id만
592
+ * 알았으므로 DB에 저장된 옛 판을 읽었다. 저장 전 변경을 두고 "왜 발행이 안 되나" 또는
593
+ * "이 부품을 줄여 달라"고 물으면 대화가 엉뚱한 판을 보게 되는 결함이었다.
594
+ *
595
+ * 이 이벤트는 저장하지 않는다. 도크가 대화 인자로만 잠시 들고 있고, 서버 도구도 도메인 소속
596
+ * Figure를 먼저 확인한 뒤에만 이 source를 사용한다. 페이지를 떠날 때는 아래에서 지워 오래된
597
+ * 초안이 다음 Figure에 섞이지 않게 한다.
598
+ */
599
+ updated(changed: Map<string, unknown>) {
600
+ if (changed.has('figure') && (changed.get('figure') as Figure | undefined)?.id !== this.figure?.id) {
601
+ this.proposalFeedback = []
602
+ }
603
+ if (changed.has('board') || changed.has('parts') || changed.has('figure')) {
604
+ window.dispatchEvent(
605
+ new CustomEvent('figure-ai-context', {
606
+ detail: { figureId: this.figure?.id, source: this.folded }
607
+ })
608
+ )
609
+ }
610
+ }
611
+
612
+ disconnectedCallback() {
613
+ window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
614
+ super.disconnectedCallback()
615
+ }
616
+
610
617
  get context() {
611
618
  return {
612
619
  title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
@@ -642,17 +649,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
642
649
  <figure-ask
643
650
  .source=${source}
644
651
  .type=${source?.type ?? ''}
652
+ .feedback=${this.proposalFeedback}
645
653
  @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
646
654
  ></figure-ask>
647
655
  ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
648
- <button
649
- dock
650
- ?on=${this.dockOpen}
651
- title=${i18next.t('figure.text.dock-explained')}
652
- @click=${() => (this.dockOpen = !this.dockOpen)}
653
- >
654
- <md-icon>forum</md-icon>${i18next.t('figure.button.dock')}
655
- </button>
656
656
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
657
657
  ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
658
658
  </button>
@@ -662,10 +662,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
662
662
  edits
663
663
  ?collapse-left=${this.collapsed.left}
664
664
  ?collapse-right=${this.collapsed.right}
665
- ?dock=${this.dockOpen}
666
665
  @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
666
+ @figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
667
667
  @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
668
668
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
669
+ @preview-sizing-fix=${(e: CustomEvent) => this.previewSizingFix(e.detail)}
670
+ @apply-sizing-fix=${(e: CustomEvent) => this.applySizingFix(e.detail)}
669
671
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
670
672
  @select-part=${(e: CustomEvent) => (this.selected = e.detail.index)}
671
673
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.index)}
@@ -689,8 +691,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
689
691
 
690
692
  <div middle>
691
693
  ${this.renderBar()}
692
- ${this.proposal ? this.renderDecision() : ''}
693
- ${this.proposal
694
+ ${this.proposalSession ? this.renderDecision() : ''}
695
+ ${this.proposalSession
694
696
  ? html`
695
697
  <div lanes>
696
698
  <div lane>
@@ -705,10 +707,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
705
707
  <div lane candidate>
706
708
  <h4>
707
709
  ${i18next.t('figure.label.proposed')}
708
- ${this.proposalOf
710
+ ${this.proposalSession.metrics
709
711
  ? html`<small>
710
- ${this.proposalOf.grade} ·
711
- ${i18next.t('figure.text.n-triangles', { count: this.proposalOf.triangles })}
712
+ ${this.proposalSession.metrics.grade} ·
713
+ ${i18next.t('figure.text.n-triangles', { count: this.proposalSession.metrics.triangles })}
714
+ ${this.proposalSession.metrics.quality ? html` · ${this.proposalSession.metrics.quality.summary}` : ''}
712
715
  </small>`
713
716
  : ''}
714
717
  </h4>
@@ -727,6 +730,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
727
730
 
728
731
  <figure-side
729
732
  .figureName=${this.figure?.name ?? ''}
733
+ .figureCategory=${this.figure?.category}
734
+ .figureTags=${this.figure?.tags}
730
735
  .figureId=${this.figure?.id ?? ''}
731
736
  .figureState=${this.figure?.state}
732
737
  .figureVersion=${this.figure?.version ?? 0}
@@ -738,71 +743,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
738
743
  .view=${this.view}
739
744
  @update-view=${(e: CustomEvent) => (this.view = e.detail.view)}
740
745
  ></figure-side>
741
-
742
- <!-- 대화 도크. 무엇을 켜고 끄는지는 renderDock 위에 적어 두었다 -->
743
- <ox-board-ai-chat
744
- .boardTools=${false}
745
- .toolCategories=${['figure-authoring']}
746
- .hostContext=${this.hostContext}
747
- .placeholder=${i18next.t('figure.text.dock-placeholder')}
748
- @board-ai-proposal-execute=${(e: CustomEvent) => this.takeProposal(e.detail.proposal)}
749
- ></ox-board-ai-chat>
750
746
  </div>
751
747
  `
752
748
  }
753
749
 
754
- /*
755
- * ── 대화 도크를 무엇으로 켜나 ───────────────────────────────────────────────
756
- *
757
- * board 의 컴포넌트를 그대로 쓰지만 **board 인 척하지 않는다.** `boardTools` 를 끄고 figure 의
758
- * 도구 범주(`figure-authoring`)만 연다. 끄지 않으면 보드 편집 도구가 함께 들려, 모델이 이
759
- * 화면에서 할 수 없는 일을 시도한다.
760
- *
761
- * board 모양의 인자(`currentBoard` · `knownTypes` · `componentSchemas` · `selectedRefids`)는
762
- * 주지 않는다 — 전부 선택이고, 채우려면 없는 것을 지어내야 한다.
763
- */
764
-
765
- /**
766
- * 어시스턴트가 「지금 무엇을 열고 있나」를 아는 길.
767
- *
768
- * 도구가 대상을 안 받았을 때 이 문맥에서 해소한다 — 사용자에게 식별자를 되묻지 않는다.
769
- * 팔레트도 함께 보낸다: 모르면 모델이 색을 지어내고, 그 후보는 검증을 지나 **그리는 시점에**
770
- * 죽는다.
771
- */
772
- private get hostContext() {
773
- return {
774
- surface: 'figure-modeller',
775
- figureId: this.figure?.id || undefined,
776
- figureType: this.board?.figureType,
777
- figureName: this.figure?.name,
778
- palette: Object.keys(activePalette() as Record<string, string>)
779
- }
780
- }
781
-
782
- /**
783
- * 도크의 후보를 받는다.
784
- *
785
- * **후보가 화면에 닿는 길은 하나뿐이다** — 말로 시키는 줄이든 도크든 `receive()` 로 모인다.
786
- * 두 길을 두면 「지금 / 후보」 두 칸을 두 곳에서 그리게 되고, 어느 쪽이 이기는지 저작자가
787
- * 매번 생각해야 한다.
788
- */
789
- private takeProposal(proposal: any) {
790
- const source = proposal?.source
791
- if (!source) {
792
- // 카드는 왔는데 후보가 없다 — 조용히 넘기지 않는다. 저작자가 눌렀으니 답이 있어야 한다.
793
- this.saveFailure = i18next.t('figure.text.proposal-had-no-source')
794
- return
795
- }
796
-
797
- this.receive({
798
- source: JSON.stringify(source),
799
- grade: proposal.score?.grade ?? '',
800
- triangles: proposal.cost?.triangles ?? 0,
801
- groups: proposal.cost?.groups ?? 0,
802
- attempts: proposal.attempts ?? 1
803
- })
804
- }
805
-
806
750
  /**
807
751
  * 가운데 칸 위의 띠 — 무엇을 보고 있나와, 양쪽 칸을 접는 손잡이.
808
752
  *
@@ -833,7 +777,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
833
777
  `
834
778
 
835
779
  return html`<div modes>
836
- ${this.renderCollapse('left')}${this.proposal ? '' : html`${one('edit')}${one('preview')}`}
780
+ ${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
837
781
  <div spacer></div>
838
782
  ${this.renderCollapse('right')}
839
783
  </div>`
@@ -868,16 +812,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
868
812
  }
869
813
 
870
814
  /**
871
- * AI 후보를 나란히 세운다.
815
+ * AI 후보를 실제 배치 크기에서도 본다.
872
816
  *
873
- * 후보는 저장 형식으로 온다. 미리보기는 모델을 받으므로 여기서 번 변환한다 —
874
- * 후보를 받아들이기 전까지는 편집 데이터가 아니므로 상태로 두지 않는다.
817
+ * 편집 캔버스 장으로는 `repeat`·`fixed`·`anchor`가 맞는지 없다. 후보는 아직
818
+ * 정본이 아니므로 저장하지 않고, `figure-preview`가 같은 FigureSource를 기준 크기와
819
+ * 일곱 가지 확장 축에서 실제 FigureInstance로 세운다. 따라서 저작자는 "좋아 보이는
820
+ * 한 장"이 아니라 배치될 때도 붙어 있는지를 보고 수락한다.
875
821
  */
876
822
  private renderProposed() {
877
- if (!this.proposal) return ''
823
+ if (!this.proposalSession) return ''
878
824
 
879
- const { board, parts } = fromFigureSource(this.proposal)
880
- return html`<figure-canvas .board=${board} .parts=${parts} .viewOnly=${true}></figure-canvas>`
825
+ return html`<figure-preview .source=${this.proposalSession.source}></figure-preview>`
881
826
  }
882
827
 
883
828
  /**
@@ -895,22 +840,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
895
840
  * 안 들고, 우리가 생각 못 한 조합을 막아 주기 때문이다.
896
841
  */
897
842
  private renderDecision() {
898
- if (!this.proposal) return ''
843
+ if (!this.proposalSession) return ''
899
844
 
900
- const changes = proposals.diffProposal(this.folded, this.proposal)
901
- const picked = proposals.applyProposal(this.folded, this.proposal, this.picked)
845
+ const changes = proposals.diffProposal(this.folded, this.proposalSession.source)
846
+ const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
902
847
  const { errors, violations } = validate(picked)
903
848
 
904
849
  return html`
905
850
  <div decide>
906
- <span>${i18next.t('figure.text.assistant-proposed-a-figure')}</span>
851
+ <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
907
852
  <div spacer></div>
908
853
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
909
- <button take ?disabled=${errors.length > 0 || this.picked.size === 0} @click=${() => this.take()}>
910
- ${i18next.t('figure.button.take-n-changes', { n: this.picked.size })}
854
+ <button take ?disabled=${errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
855
+ ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
911
856
  </button>
912
857
  </div>
913
858
 
859
+ ${this.renderQualityReview()}
860
+ <div proposal-note>
861
+ <label>
862
+ ${i18next.t('figure.text.proposal-feedback-note')}
863
+ <input
864
+ maxlength="500"
865
+ .value=${this.proposalSession.note}
866
+ placeholder=${i18next.t('figure.text.proposal-feedback-placeholder')}
867
+ @input=${(event: Event) => (this.proposalSession = { ...this.proposalSession!, note: (event.target as HTMLInputElement).value })}
868
+ />
869
+ </label>
870
+ </div>
871
+
914
872
  ${changes.length > 0
915
873
  ? html`
916
874
  <div changes>
@@ -926,6 +884,25 @@ export class FigureModellerPage extends FigureModellerPageBase {
926
884
  `
927
885
  }
928
886
 
887
+ /** 후보 품질 판단의 근거를 수락 전에 전부 보여 준다. */
888
+ private renderQualityReview() {
889
+ const quality = this.proposalSession?.metrics?.quality
890
+ if (!quality) return ''
891
+
892
+ return html`
893
+ <div quality-review ?review=${quality.status === 'review'}>
894
+ <strong>${i18next.t('figure.label.candidate-review')}</strong>
895
+ <span>${quality.summary}</span>
896
+ <span> · top-view ${Math.round(quality.visualReadability * 100)}% · ${quality.visualRegions} regions</span>
897
+ ${quality.findings.length > 0
898
+ ? html`<ul>
899
+ ${quality.findings.map(finding => html`<li><code>${finding.dimension}</code> — ${finding.message}</li>`)}
900
+ </ul>`
901
+ : ''}
902
+ </div>
903
+ `
904
+ }
905
+
929
906
  /** 변경 하나. 무엇을 하겠다는 것인지 부호로 먼저 말한다. */
930
907
  private renderChange(change: proposals.ProposalChange) {
931
908
  const icon = {
@@ -938,7 +915,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
938
915
  styleKit: 'palette'
939
916
  }[change.kind]
940
917
 
941
- const on = this.picked.has(change.key)
918
+ const on = this.proposalSession?.picked.has(change.key)
942
919
 
943
920
  return html`
944
921
  <button
@@ -954,10 +931,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
954
931
  }
955
932
 
956
933
  private toggle(key: string) {
957
- const next = new Set(this.picked)
958
- if (next.has(key)) next.delete(key)
959
- else next.add(key)
960
- this.picked = next
934
+ if (this.proposalSession) this.proposalSession = proposalSessions.toggleProposalChange(this.proposalSession, key)
961
935
  }
962
936
 
963
937
  /**
@@ -966,22 +940,29 @@ export class FigureModellerPage extends FigureModellerPageBase {
966
940
  * 원본 데이터에 넣지 않는다. 나란히 두고 저작자가 고른다. AI 가 원본 데이터에 직접 쓰면
967
941
  * 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
968
942
  */
969
- private receive(proposal: {
970
- source: string
971
- grade: string
972
- triangles: number
973
- groups: number
974
- attempts: number
975
- }) {
976
- this.proposal = JSON.parse(proposal.source) as FigureSource
977
- // 처음에는 전부 골라 둔다 — 통째로 받는 것이 보통의 길이다.
978
- this.picked = new Set(proposals.diffProposal(this.folded, this.proposal).map(change => change.key))
979
- this.proposalOf = {
980
- grade: proposal.grade,
981
- triangles: proposal.triangles,
982
- groups: proposal.groups,
983
- attempts: proposal.attempts
984
- }
943
+ private receive(proposal: FigureProposal) {
944
+ this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal)
945
+ }
946
+
947
+ /**
948
+ * 발행 게이트의 구조화된 수정안 하나를 현재 작업 사본에만 겹쳐 본다. 서버가 준 값은
949
+ * `part.anchor.axis = to`라는 데이터이지 사람이 읽을 문장을 다시 해석한 결과가 아니다.
950
+ * 원본 `board`/`parts`는 여기서 전혀 바꾸지 않고, 아래의 `take()`만 바꾼 사본을 적용한다.
951
+ */
952
+ private previewSizingFix(fix: FigureGateFix) {
953
+ if (!this.folded) return
954
+ this.proposalSession = proposalSessions.openSizingFix(this.folded, fix)
955
+ }
956
+
957
+ /**
958
+ * 원클릭 적용은 서버가 모든 크기 변경 case를 재검사해 안전하다고 표시한 한-앵커 후보에만 연다.
959
+ * 그래도 저장은 저작자의 별도 행위다. 잘못 눌러도 DB 정본이나 발행판은 바뀌지 않는다.
960
+ */
961
+ private applySizingFix(fix: FigureGateFix) {
962
+ if (!fix.safe || !this.folded) return
963
+ this.previewSizingFix(fix)
964
+ if (!this.proposalSession) return
965
+ this.take()
985
966
  }
986
967
 
987
968
  /**
@@ -991,22 +972,45 @@ export class FigureModellerPage extends FigureModellerPageBase {
991
972
  * 하므로 그쪽이 자연스럽다. 받아들인 결과는 다시 씬 모델로 되돌려 편집을 이어간다.
992
973
  */
993
974
  private take() {
994
- if (!this.proposal) return
975
+ if (!this.proposalSession) return
995
976
 
996
- const taken = proposals.applyProposal(this.folded, this.proposal, this.picked)
977
+ const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
997
978
  const { board, parts } = fromFigureSource(taken)
998
979
 
999
980
  this.board = board
1000
981
  this.parts = parts
1001
982
  this.selected = parts.length ? 0 : -1
1002
983
  this.dirty = true
1003
- this.discard()
984
+ this.reportProposalFeedback('accepted')
985
+ this.discard(false)
986
+ }
987
+
988
+ /**
989
+ * 후보에 대한 사람의 결정을 도크에만 알린다.
990
+ *
991
+ * 이 기록은 분석 이벤트나 Figure 속성이 아니다. 같은 모델러 세션에서 다음 요청을 더 잘
992
+ * 이해시키는 짧은 신호일 뿐이다. 원본 source나 사람의 작업 내용을 다시 보내지 않고, 사람이
993
+ * 후보 변경 중 몇 개를 받아들였는지와 이미 화면에 보였던 품질 판정만 보낸다.
994
+ */
995
+ private reportProposalFeedback(outcome: 'accepted' | 'discarded') {
996
+ if (!this.proposalSession) return
997
+
998
+ const feedback = proposalSessions.feedbackFromSession(this.folded, this.proposalSession, outcome)
999
+ if (!feedback) return
1000
+ this.proposalFeedback = [...this.proposalFeedback, feedback].slice(-5)
1001
+ window.dispatchEvent(
1002
+ new CustomEvent('figure-ai-feedback', {
1003
+ detail: {
1004
+ figureId: this.figure?.id,
1005
+ ...feedback
1006
+ }
1007
+ })
1008
+ )
1004
1009
  }
1005
1010
 
1006
- private discard() {
1007
- this.proposal = undefined
1008
- this.proposalOf = undefined
1009
- this.picked = new Set()
1011
+ private discard(reportFeedback = true) {
1012
+ if (reportFeedback) this.reportProposalFeedback('discarded')
1013
+ this.proposalSession = undefined
1010
1014
  }
1011
1015
 
1012
1016
  /**
@@ -1135,6 +1139,19 @@ export class FigureModellerPage extends FigureModellerPageBase {
1135
1139
  this.dirty = true
1136
1140
  }
1137
1141
 
1142
+ /**
1143
+ * 분류와 태그를 고친다.
1144
+ *
1145
+ * 정본(`source`)이 아니라 **카탈로그의 값**이다 — 도형이 무엇으로 생겼는지가 아니라 어디에
1146
+ * 걸리는지다. 그래서 점수도 발행도 흔들리지 않는다.
1147
+ */
1148
+ private catalog(patch: { category?: string; tags?: string[] }) {
1149
+ if (!this.figure) return
1150
+
1151
+ this.figure = { ...this.figure, ...patch }
1152
+ this.dirty = true
1153
+ }
1154
+
1138
1155
  /**
1139
1156
  * 부품 목록을 갈아 끼운다.
1140
1157
  *
@@ -1339,6 +1356,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
1339
1356
  if (this.figure.id) {
1340
1357
  const result = await updateFigure(this.figure.id, {
1341
1358
  name: this.figure.name,
1359
+ /*
1360
+ 카탈로그의 값도 함께 보낸다. 화면에서 고쳐 놓고 저장에 안 실으면 저장 뒤에 조용히
1361
+ 사라진다 — 저작자는 저장했다고 보고 다시 열면 없다.
1362
+ */
1363
+ category: this.figure.category,
1364
+ tags: this.figure.tags,
1342
1365
  source: sourceJson,
1343
1366
  thumbnail
1344
1367
  })
@@ -1355,6 +1378,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
1355
1378
  const result = await createFigure({
1356
1379
  type: this.folded.type,
1357
1380
  name: this.figure.name,
1381
+ category: this.figure.category,
1382
+ tags: this.figure.tags,
1358
1383
  source: sourceJson,
1359
1384
  thumbnail
1360
1385
  })
package/client/types.ts CHANGED
@@ -14,7 +14,10 @@ export interface Figure {
14
14
  type: string
15
15
  name: string
16
16
  description?: string
17
+ /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */
17
18
  category?: string
19
+ /** 태그 여럿 — 분류로는 못 하는 찾기. */
20
+ tags?: string[]
18
21
  state?: 'draft' | 'released'
19
22
  /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */
20
23
  score?: number
@@ -63,6 +66,7 @@ export interface NewFigure {
63
66
  name: string
64
67
  description?: string
65
68
  category?: string
69
+ tags?: string[]
66
70
  source: string
67
71
  properties?: string
68
72
  state?: 'draft' | 'released'
@@ -79,6 +83,8 @@ export interface FigurePatch {
79
83
  name?: string
80
84
  description?: string
81
85
  category?: string
86
+ /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */
87
+ tags?: string[]
82
88
  source?: string
83
89
  properties?: string
84
90
  state?: 'draft' | 'released'
@@ -106,12 +112,31 @@ export interface FigureProposal {
106
112
  triangles: number
107
113
  /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */
108
114
  groups: number
115
+ /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */
116
+ quality: {
117
+ status: 'ready' | 'review'
118
+ summary: string
119
+ findings: Array<{ dimension: string; code: string; message: string }>
120
+ visualRegions: number
121
+ visualCoverage: number
122
+ visualReadability: number
123
+ }
109
124
  /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */
110
125
  violations: FigureFinding[]
111
126
  /** 몇 번 만에 됐나. 진단용이다. */
112
127
  attempts: number
113
128
  }
114
129
 
130
+ /** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */
131
+ export interface ProposalFeedback {
132
+ outcome: 'accepted' | 'discarded'
133
+ selectedChanges: number
134
+ totalChanges: number
135
+ note?: string
136
+ grade?: string
137
+ quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }
138
+ }
139
+
115
140
  /**
116
141
  * 발행 판정 한 줄.
117
142
  *
@@ -119,6 +144,18 @@ export interface FigureProposal {
119
144
  * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고
120
145
  * 화면이 다시 분류하면 규칙이 두 벌이 된다.
121
146
  */
147
+ /** One reversible anchor edit the sizing gate has measured against this exact source. */
148
+ export interface FigureGateFix {
149
+ findingWay: string
150
+ part: string
151
+ axis: 'x' | 'y' | 'z'
152
+ from?: string
153
+ to: 'min' | 'center' | 'max' | 'span'
154
+ resolved: number
155
+ /** All blocking sizing cases clear after this exact edit was remeasured server-side. */
156
+ safe: boolean
157
+ }
158
+
122
159
  export interface FigureGateFinding {
123
160
  code: string
124
161
  message: string
@@ -128,6 +165,8 @@ export interface FigureGateFinding {
128
165
  how?: string
129
166
  /** 갈라진 부품 이름들, 또는 정본 안의 경로. */
130
167
  at?: string
168
+ /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */
169
+ fixes?: FigureGateFix[]
131
170
  blocking: boolean
132
171
  }
133
172