@things-factory/figure-ui 10.1.7 → 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 (39) hide show
  1. package/client/graphql/index.ts +24 -0
  2. package/client/modeller/figure-animations.ts +61 -4
  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 +477 -124
  6. package/client/modeller/proposal-session.ts +92 -0
  7. package/client/pages/figure-modeller-page.ts +179 -53
  8. package/client/types.ts +33 -0
  9. package/dist-client/graphql/index.d.ts +3 -1
  10. package/dist-client/graphql/index.js +21 -0
  11. package/dist-client/graphql/index.js.map +1 -1
  12. package/dist-client/modeller/figure-animations.d.ts +4 -1
  13. package/dist-client/modeller/figure-animations.js +57 -4
  14. package/dist-client/modeller/figure-animations.js.map +1 -1
  15. package/dist-client/modeller/figure-ask.d.ts +3 -0
  16. package/dist-client/modeller/figure-ask.js +9 -0
  17. package/dist-client/modeller/figure-ask.js.map +1 -1
  18. package/dist-client/modeller/figure-history-panel.d.ts +7 -0
  19. package/dist-client/modeller/figure-history-panel.js +50 -0
  20. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  21. package/dist-client/modeller/figure-inspector.d.ts +3 -0
  22. package/dist-client/modeller/figure-inspector.js +464 -111
  23. package/dist-client/modeller/figure-inspector.js.map +1 -1
  24. package/dist-client/modeller/proposal-session.d.ts +23 -0
  25. package/dist-client/modeller/proposal-session.js +65 -0
  26. package/dist-client/modeller/proposal-session.js.map +1 -0
  27. package/dist-client/pages/figure-modeller-page.d.ts +43 -9
  28. package/dist-client/pages/figure-modeller-page.js +173 -50
  29. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-client/types.d.ts +38 -0
  32. package/dist-client/types.js.map +1 -1
  33. package/package.json +4 -4
  34. package/test/ai-proposal-contract.test.ts +169 -0
  35. package/translations/en.json +32 -0
  36. package/translations/ja.json +32 -0
  37. package/translations/ko.json +42 -0
  38. package/translations/ms.json +32 -0
  39. package/translations/zh.json +32 -0
@@ -8,6 +8,7 @@ import type {
8
8
  FigureListResult,
9
9
  FigurePatch,
10
10
  FigureProposal,
11
+ ProposalFeedback,
11
12
  FigureVersion,
12
13
  NewFigure
13
14
  } from '../types.js'
@@ -196,6 +197,8 @@ export async function proposeFigure(request: {
196
197
  base?: string
197
198
  type?: string
198
199
  palette: string[]
200
+ refine?: boolean
201
+ feedback?: ProposalFeedback[]
199
202
  image?: File
200
203
  }): Promise<FigureProposal> {
201
204
  const response = await client.mutate({
@@ -207,6 +210,18 @@ export async function proposeFigure(request: {
207
210
  grade
208
211
  triangles
209
212
  groups
213
+ quality {
214
+ status
215
+ summary
216
+ findings {
217
+ dimension
218
+ code
219
+ message
220
+ }
221
+ visualRegions
222
+ visualCoverage
223
+ visualReadability
224
+ }
210
225
  attempts
211
226
  violations {
212
227
  code
@@ -280,6 +295,15 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
280
295
  why
281
296
  how
282
297
  at
298
+ fixes {
299
+ findingWay
300
+ part
301
+ axis
302
+ from
303
+ to
304
+ resolved
305
+ safe
306
+ }
283
307
  blocking
284
308
  }
285
309
  }
@@ -39,6 +39,23 @@ type Clip = NonNullable<BoardModel['animations']>[number]
39
39
  type Channel = Clip['channels'][number]
40
40
  type Keyframe = Channel['keys'][number]
41
41
 
42
+ /** A first-success motion: intent first, raw clip/channel editing second. */
43
+ interface MotionRecipe {
44
+ id: 'conveyor' | 'gate' | 'pusher' | 'lift'
45
+ name: string
46
+ drive: Clip['drive']
47
+ path: ChannelPath
48
+ end: Vec3
49
+ pivot?: Vec3
50
+ }
51
+
52
+ const MOTION_RECIPES: readonly MotionRecipe[] = [
53
+ { id: 'conveyor', name: 'conveyor-running', drive: 'loop', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
54
+ { id: 'gate', name: 'gate-open', drive: 'hold', path: 'rotation', end: { x: 0, y: 90, z: 0 }, pivot: { x: 0, y: 0, z: 0 } },
55
+ { id: 'pusher', name: 'pusher-position', drive: 'hold', path: 'translation', end: { x: 100, y: 0, z: 0 } },
56
+ { id: 'lift', name: 'lift-height', drive: 'hold', path: 'translation', end: { x: 0, y: 100, z: 0 } }
57
+ ]
58
+
42
59
  /** 새 clip 의 키 둘. **하나로 만들지 않는다** — 키 하나는 움직임이 아니라 자세다. */
43
60
  const NEW_KEYS: Keyframe[] = [
44
61
  { at: 0, value: { x: 0, y: 0, z: 0 } },
@@ -63,6 +80,12 @@ function freeName(taken: readonly Clip[]): string {
63
80
  }
64
81
  }
65
82
 
83
+ function freeRecipeName(taken: readonly Clip[], base: string): string {
84
+ const used = new Set(taken.map(clip => clip.name))
85
+ if (!used.has(base)) return base
86
+ for (let n = 2; ; n++) if (!used.has(`${base}-${n}`)) return `${base}-${n}`
87
+ }
88
+
66
89
  @customElement('figure-animations')
67
90
  export class FigureAnimations extends localize(i18next)(LitElement) {
68
91
  static styles = [
@@ -104,6 +127,11 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
104
127
  color: var(--md-sys-color-on-surface-variant);
105
128
  }
106
129
 
130
+ div[recipes] { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
131
+ button[recipe] { min-height: 54px; align-items: flex-start; flex-direction: column; text-align: left; }
132
+ button[recipe] strong { font-size: 0.74rem; }
133
+ button[recipe] span { color: var(--md-sys-color-on-surface-variant); font-size: 0.68rem; line-height: 1.35; }
134
+
107
135
  /* ── clip ─────────────────────────────────────────────────────── */
108
136
 
109
137
  div[clip] {
@@ -263,6 +291,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
263
291
 
264
292
  /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
265
293
  @state() private open = 0
294
+ @state() private recipeTarget = ''
266
295
 
267
296
  private get clips(): Clip[] {
268
297
  return this.board?.animations ?? []
@@ -294,7 +323,8 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
294
323
  <p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
295
324
  </section>
296
325
 
297
- ${this.clips.length === 0 ? this.renderEmpty() : this.renderClips()}
326
+ ${this.renderRecipes()}
327
+ ${this.clips.length === 0 ? nothing : this.renderClips()}
298
328
 
299
329
  <section>
300
330
  <button @click=${() => this.addClip()} ?disabled=${this.partNames.length === 0}>
@@ -315,11 +345,24 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
315
345
  * 「clip 을 추가하세요」라고만 쓰면 처음 여는 사람은 clip 이 무엇인지 모른 채 단추를 누른다.
316
346
  * 그래서 무엇을 만들 수 있는지 예를 들고, 그것이 어떻게 움직이는지(사실이 들어온다)를 적는다.
317
347
  */
318
- private renderEmpty(): TemplateResult {
348
+ private renderRecipes(): TemplateResult {
319
349
  return html`
320
350
  <section>
321
- <p quiet>${i18next.t('figure.text.animation-empty-what-it-is')}</p>
322
- <p quiet>${i18next.t('figure.text.animation-empty-examples')}</p>
351
+ <h3>${i18next.t('figure.label.motion-intent')}</h3>
352
+ <p quiet>${i18next.t('figure.text.motion-intent-help')}</p>
353
+ ${this.partNames.length
354
+ ? html`<select .value=${this.recipeTarget} @change=${(e: Event) => (this.recipeTarget = (e.target as HTMLSelectElement).value)}>
355
+ ${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
356
+ </select>
357
+ <div recipes>
358
+ ${MOTION_RECIPES.map(recipe => html`
359
+ <button recipe @click=${() => this.addRecipe(recipe)}>
360
+ <strong>${i18next.t('figure.label.recipe-' + recipe.id)}</strong>
361
+ <span>${i18next.t('figure.text.recipe-' + recipe.id)}</span>
362
+ </button>
363
+ `)}
364
+ </div>`
365
+ : html`<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>`}
323
366
  </section>
324
367
  `
325
368
  }
@@ -622,6 +665,20 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
622
665
  this.tell([...this.clips, clip])
623
666
  }
624
667
 
668
+ /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
669
+ private addRecipe(recipe: MotionRecipe): void {
670
+ const target = this.recipeTarget || this.partNames[0]
671
+ if (!target) return
672
+ const channel: Channel = {
673
+ target,
674
+ path: recipe.path,
675
+ keys: [{ at: 0, value: startValue(recipe.path) }, { at: 1, value: { ...recipe.end } }]
676
+ }
677
+ if (recipe.pivot) channel.pivot = { ...recipe.pivot }
678
+ this.open = this.clips.length
679
+ this.tell([...this.clips, { name: freeRecipeName(this.clips, recipe.name), drive: recipe.drive, channels: [channel] }])
680
+ }
681
+
625
682
  private renameClip(at: number, name: string): void {
626
683
  this.patchClip(at, { name: name.trim() })
627
684
  }
@@ -8,7 +8,7 @@ import type { FigureSource } from '@hatiolab/figure-model'
8
8
  import { activePalette } from '@hatiolab/things-scene'
9
9
 
10
10
  import { proposeFigure } from '../graphql/index.js'
11
- import type { FigureProposal } from '../types.js'
11
+ import type { FigureProposal, ProposalFeedback } from '../types.js'
12
12
 
13
13
  /**
14
14
  * 말로 시키는 줄.
@@ -175,6 +175,8 @@ export class FigureAsk extends localize(i18next)(LitElement) {
175
175
  @property({ type: Object }) source?: FigureSource
176
176
  /** 새로 만들 때 쓸 타입 이름. */
177
177
  @property({ type: String }) type = ''
178
+ /** 이 모델러 세션에서 사람이 명시적으로 남긴 이전 후보 반응. */
179
+ @property({ type: Array }) feedback: ProposalFeedback[] = []
178
180
 
179
181
  @state() private asking = false
180
182
  @state() private failure = ''
@@ -239,6 +241,9 @@ export class FigureAsk extends localize(i18next)(LitElement) {
239
241
  type: this.source ? undefined : this.type,
240
242
  // 그리는 쪽이 풀 수 있는 토큰만 보낸다. 서버는 이 목록 밖의 색을 거절한다.
241
243
  palette: Object.keys(activePalette() as Record<string, string>),
244
+ // 구조적으로는 맞지만 품질 근거가 남으면 한 번 더 개선 후보를 시킨다.
245
+ refine: true,
246
+ feedback: this.feedback,
242
247
  image: this.picture
243
248
  })
244
249
 
@@ -7,7 +7,7 @@ import { i18next, localize } from '@operato/i18n'
7
7
  import { ScrollbarStyles } from '@operato/styles'
8
8
 
9
9
  import { fetchFigureVersions, inspectFigure, releaseFigure, revertFigureVersion } from '../graphql/index.js'
10
- import type { FigureGateFinding, FigureInspection, FigureVersion } from '../types.js'
10
+ import type { FigureGateFinding, FigureGateFix, FigureInspection, FigureVersion } from '../types.js'
11
11
 
12
12
  /**
13
13
  * 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
@@ -253,6 +253,33 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
253
253
  margin-top: 1px;
254
254
  }
255
255
 
256
+ /* 설명으로 끝내지 않는다. 이 단추는 원본을 바꾸지 않고 모델러에 후보만 연다. */
257
+ li[finding] button[preview-fix] {
258
+ margin-top: 3px;
259
+ padding: 4px 7px;
260
+ font-weight: 700;
261
+ color: var(--md-sys-color-on-primary-container, var(--md-sys-color-on-surface));
262
+ background-color: var(--md-sys-color-primary-container, transparent);
263
+ border: 0;
264
+ border-radius: 5px;
265
+ cursor: pointer;
266
+ }
267
+
268
+ li[finding] button[preview-fix]:hover {
269
+ filter: brightness(1.08);
270
+ }
271
+
272
+ li[finding] button[apply-fix] {
273
+ margin-top: 3px;
274
+ padding: 4px 7px;
275
+ font-weight: 700;
276
+ color: var(--ui-on-accent, #fff);
277
+ background-color: var(--md-sys-color-primary);
278
+ border: 0;
279
+ border-radius: 5px;
280
+ cursor: pointer;
281
+ }
282
+
256
283
  p[quiet] {
257
284
  margin: 0;
258
285
  line-height: 1.6;
@@ -408,10 +435,34 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
408
435
  ${finding.at ? html`<code where>${finding.at}</code>` : nothing}
409
436
  ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
410
437
  ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
438
+ ${(finding.fixes ?? []).map(fix => html`
439
+ ${fix.safe ? html`
440
+ <button apply-fix @click=${() => this.applyFix(fix)}>
441
+ 안전하게 수정 적용 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}
442
+ </button>
443
+ ` : nothing}
444
+ <button preview-fix @click=${() => this.previewFix(fix)}>
445
+ 수정안 미리보기 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}
446
+ </button>
447
+ `)}
411
448
  </li>
412
449
  `
413
450
  }
414
451
 
452
+ /**
453
+ * 게이트는 수정을 저장하지 않는다. 부모 모델러가 현재 정본과 이 한 수정안을 나란히 렌더하고,
454
+ * 저작자가 '변경 받기'를 눌렀을 때에만 자기 작업 사본에 적용한다.
455
+ */
456
+ private previewFix(fix: FigureGateFix) {
457
+ this.dispatchEvent(new CustomEvent('preview-sizing-fix', { detail: fix, bubbles: true, composed: true }))
458
+ }
459
+
460
+ /** 서버가 전수 재검사해 모든 차단 split이 사라진 후보만 작업 사본에 원클릭 적용한다. 저장은 하지 않는다. */
461
+ private applyFix(fix: FigureGateFix) {
462
+ if (!fix.safe) return
463
+ this.dispatchEvent(new CustomEvent('apply-sizing-fix', { detail: fix, bubbles: true, composed: true }))
464
+ }
465
+
415
466
  private renderVersion(version: FigureVersion) {
416
467
  return html`
417
468
  <li>