@things-factory/figure-ui 10.1.25 → 10.1.27

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 (50) hide show
  1. package/client/graphql/index.ts +4 -1
  2. package/client/modeller/figure-animations.ts +229 -29
  3. package/client/modeller/figure-history-panel.ts +2 -2
  4. package/client/modeller/figure-inspector.ts +151 -5
  5. package/client/modeller/figure-preview.ts +5 -5
  6. package/client/modeller/figure-source.ts +33 -45
  7. package/client/modeller/figure-view.ts +20 -0
  8. package/client/modeller/joint-edits.ts +201 -0
  9. package/client/modeller/proposal-session.ts +8 -2
  10. package/client/pages/figure-modeller-page.ts +50 -15
  11. package/dist-client/graphql/index.js +4 -1
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/modeller/figure-animations.d.ts +19 -0
  14. package/dist-client/modeller/figure-animations.js +206 -22
  15. package/dist-client/modeller/figure-animations.js.map +1 -1
  16. package/dist-client/modeller/figure-history-panel.js +2 -2
  17. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  18. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  19. package/dist-client/modeller/figure-inspector.js +124 -4
  20. package/dist-client/modeller/figure-inspector.js.map +1 -1
  21. package/dist-client/modeller/figure-preview.js +5 -5
  22. package/dist-client/modeller/figure-preview.js.map +1 -1
  23. package/dist-client/modeller/figure-source.d.ts +12 -1
  24. package/dist-client/modeller/figure-source.js +13 -40
  25. package/dist-client/modeller/figure-source.js.map +1 -1
  26. package/dist-client/modeller/figure-view.d.ts +16 -0
  27. package/dist-client/modeller/figure-view.js +15 -0
  28. package/dist-client/modeller/figure-view.js.map +1 -1
  29. package/dist-client/modeller/joint-edits.d.ts +29 -0
  30. package/dist-client/modeller/joint-edits.js +175 -0
  31. package/dist-client/modeller/joint-edits.js.map +1 -0
  32. package/dist-client/modeller/proposal-session.d.ts +5 -0
  33. package/dist-client/modeller/proposal-session.js +3 -2
  34. package/dist-client/modeller/proposal-session.js.map +1 -1
  35. package/dist-client/pages/figure-modeller-page.d.ts +2 -22
  36. package/dist-client/pages/figure-modeller-page.js +46 -15
  37. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  38. package/dist-client/tsconfig.tsbuildinfo +1 -0
  39. package/dist-server/tsconfig.tsbuildinfo +1 -0
  40. package/package.json +3 -3
  41. package/test/ai-proposal-contract.test.ts +3 -2
  42. package/test/figure-source.test.ts +43 -57
  43. package/test/i18n-prefix-guard.test.ts +2 -3
  44. package/test/joint-edits.test.ts +130 -0
  45. package/test/preview-board-depth.test.ts +27 -0
  46. package/translations/en.json +119 -80
  47. package/translations/ja.json +208 -58
  48. package/translations/ko.json +139 -100
  49. package/translations/ms.json +206 -56
  50. package/translations/zh.json +207 -57
@@ -1,4 +1,11 @@
1
- import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePlacement, type FigureSource, type Vec3 } from '@hatiolab/figure-model'
1
+ import {
2
+ FIGURE_SOURCE_VERSION,
3
+ type DetailLevel,
4
+ type FigurePart,
5
+ type FigurePlacement,
6
+ type FigureSource,
7
+ type Vec3
8
+ } from '@hatiolab/figure-model'
2
9
 
3
10
  /**
4
11
  * Scene model <-> `FigureSource`.
@@ -40,9 +47,12 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
40
47
  * size.z height
41
48
  *
42
49
  * position.x left + width/2 - draft width/2
43
- * position.y zPos + depth/2 - draft depth/2 <- zPos is the underside
50
+ * position.y zPos + depth/2 <- both measured from the box bottom
44
51
  * position.z top + height/2 - draft height/2
45
52
  *
53
+ * (Source edition 2 measures y from the bottom face of the base box, as the scene's zPos does, so
54
+ * that row needs no half-box shift. x and z are still measured from the box centre. ADR-0065.)
55
+ *
46
56
  * rotation.x rotationX (scene radians, format degrees)
47
57
  * rotation.y -rotation
48
58
  * rotation.z rotationY
@@ -118,6 +128,12 @@ export interface FigureDraft {
118
128
  * 주나**를 말하는 다른 칸이다.
119
129
  */
120
130
  capabilities?: FigureSource['capabilities']
131
+ /**
132
+ * How parts move relative to their parents (ADR-0066). Kept in figure coordinates as stored, the same
133
+ * terms the inspector shows. Carried even when nothing edits them, so opening and saving never drops
134
+ * them.
135
+ */
136
+ joints?: FigureSource['joints']
121
137
  }
122
138
 
123
139
  /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
@@ -192,6 +208,8 @@ export interface PartModel {
192
208
  * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
193
209
  */
194
210
  capability?: FigurePart['capability']
211
+ /** The part this one is attached to (ADR-0066). The one place a parent is written. */
212
+ parent?: FigurePart['parent']
195
213
  }
196
214
 
197
215
  const RAD_TO_DEG = 180 / Math.PI
@@ -283,7 +301,8 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
283
301
  parts: parts.map(part => partTo(part, half)),
284
302
  animations: draft.animations,
285
303
  parameters: draft.parameters,
286
- capabilities: draft.capabilities
304
+ capabilities: draft.capabilities,
305
+ joints: draft.joints?.length ? draft.joints : undefined
287
306
  }) as FigureSource
288
307
  }
289
308
 
@@ -317,7 +336,7 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
317
336
  transform: withoutEmpty({
318
337
  position: {
319
338
  x: round4(part.left + part.width / 2 - half.x),
320
- y: round4(part.zPos + part.depth / 2 - half.y),
339
+ y: round4(part.zPos + part.depth / 2),
321
340
  z: round4(part.top + part.height / 2 - half.z)
322
341
  },
323
342
  size: { x: part.width, y: part.depth, z: part.height },
@@ -332,48 +351,14 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
332
351
  anchor: part.anchor,
333
352
  repeat: part.repeat,
334
353
  label: part.label,
335
- capability: part.capability
354
+ capability: part.capability,
355
+ parent: part.parent
336
356
  }) as FigurePart
337
357
  }
338
358
 
339
- /**
340
- * 옛 `drive: 'hold'` clip 을 파라미터로 옮긴다 — **여는 순간에.**
341
- *
342
- * 형식은 값이 만드는 자세를 `parameters` 로 옮겼고(ADR-0051 ①), `compile()` 이 옛 원본을
343
- * 청사진으로 만들 때 같은 이전을 한다. **거기서만 하면 저작 화면이 어긋난다** — 씬은
344
- * 파라미터로 세우는데 저작 화면은 원본을 그대로 읽어 애니메이션 칸에 놓는다.
345
- *
346
- * 실제로 그랬다. 카탈로그의 리프터를 열면 `lift-height` 가 애니메이션 목록에 앉고,
347
- * 미리보기는 그것에 **배속 손잡이**를 붙인다. 손잡이를 끝까지 밀어도 아무 일도 안 난다 —
348
- * 청사진에는 그 이름의 clip 이 없고 파라미터로 가 있기 때문이다. 저작자가 보는 것은
349
- * 「움직이지 않는 리프터」다.
350
- *
351
- * 범위는 `%` 0~100 이다. 옛 clip 은 제 수가 무엇인지 말한 적이 없으므로 **지어내지
352
- * 않는다** — 저작자가 mm 로 고쳐 쓸 자리를 열어 줄 뿐이다.
353
- */
354
- function partedMotion(source: FigureSource): Pick<FigureDraft, 'animations' | 'parameters'> {
355
- const animations: NonNullable<FigureDraft['animations']> = []
356
- const parameters: NonNullable<FigureDraft['parameters']> = [...(source.parameters ?? [])]
357
-
358
- for (const clip of source.animations ?? []) {
359
- const { drive, ...rest } = clip as typeof clip & { drive?: string }
360
- if (drive !== 'hold') {
361
- animations.push(rest)
362
- continue
363
- }
364
- parameters.push({ name: clip.name, range: { unit: '%', min: 0, max: 100 }, clip: { channels: clip.channels } })
365
- }
366
-
367
- return {
368
- animations: animations.length > 0 ? animations : undefined,
369
- parameters: parameters.length > 0 ? parameters : undefined
370
- }
371
- }
372
-
373
359
  /** 형식을 씬 모델로 편다. */
374
360
  export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
375
361
  const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
376
- const motion = partedMotion(source)
377
362
 
378
363
  const draft = withoutEmpty({
379
364
  version: source.version,
@@ -384,9 +369,11 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
384
369
  placement: source.placement,
385
370
  detailLevel: source.detailLevel,
386
371
  styleKit: source.styleKit,
387
- animations: motion.animations,
388
- parameters: motion.parameters,
389
- capabilities: source.capabilities
372
+ /* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
373
+ animations: source.animations?.length ? source.animations : undefined,
374
+ parameters: source.parameters?.length ? source.parameters : undefined,
375
+ capabilities: source.capabilities,
376
+ joints: source.joints?.length ? source.joints : undefined
390
377
  }) as FigureDraft
391
378
 
392
379
  return { draft, parts: source.parts.map(part => partFrom(part, half)) }
@@ -405,7 +392,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
405
392
  top: round4(half.z + position.z - size.z / 2),
406
393
  width: size.x,
407
394
  height: size.z,
408
- zPos: round4(half.y + position.y - size.y / 2),
395
+ zPos: round4(position.y - size.y / 2),
409
396
  depth: size.y,
410
397
 
411
398
  /*
@@ -436,6 +423,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
436
423
  anchor: part.anchor,
437
424
  repeat: part.repeat,
438
425
  label: part.label,
439
- capability: part.capability
426
+ capability: part.capability,
427
+ parent: part.parent
440
428
  }) as PartModel
441
429
  }
@@ -130,3 +130,23 @@ export const DEFAULT_VIEW: ViewSettings = {
130
130
  dirShadowEnabled: true,
131
131
  hemiIntensity: 4.0
132
132
  }
133
+
134
+ /**
135
+ * How tall the preview board is: enough room for the tallest instance where the figure actually stands.
136
+ *
137
+ * A floor or center figure stands on the floor, so the tallest instance is the whole height. Only a
138
+ * ceiling figure needs the room up to the ceiling. The board used to reach the ceiling for every figure,
139
+ * which made a 920 tall AGV preview 4400 tall.
140
+ *
141
+ * This is an envelope, not a placement rule: where each instance goes is still the scene's
142
+ * (`FigureRealObject.effectiveZPos`), and the camera fits what the scene drew.
143
+ */
144
+ export function previewBoardDepth(
145
+ figure: { placement?: 'floor' | 'ceiling' | 'center'; base: { y: number } },
146
+ ceilingHeight: number,
147
+ factor: number
148
+ ): number {
149
+ const tallest = figure.base.y * factor
150
+ const reach = figure.placement === 'ceiling' ? Math.max(ceilingHeight, tallest) : tallest
151
+ return Math.round(reach * 1.1)
152
+ }
@@ -0,0 +1,201 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureJoint, FigurePart, JointType, Vec3 } from '@hatiolab/figure-model'
6
+
7
+ import type { FigureDraft, PartModel } from './figure-source.js'
8
+
9
+ /**
10
+ * Editing joints and parents (ADR-0066). Pure functions over the draft and the part list, so the inspector
11
+ * only renders and dispatches.
12
+ *
13
+ * A joint is always created together with the parameter that drives it. Values reach a joint only through
14
+ * `parameters`, and the preview draws a slider per parameter, so a joint without one could be defined but
15
+ * never seen moving.
16
+ */
17
+
18
+ type Parameter = NonNullable<FigureDraft['parameters']>[number]
19
+
20
+ /** The limits a new joint starts with. Revolute in degrees, prismatic in millimetres. */
21
+ const NEW_LIMITS: Record<Exclude<JointType, 'continuous'>, { min: number; max: number }> = {
22
+ revolute: { min: -90, max: 90 },
23
+ prismatic: { min: 0, max: 500 }
24
+ }
25
+
26
+ /** The span a parameter driving a continuous joint offers: one turn either way. */
27
+ const CONTINUOUS_SPAN = { min: -360, max: 360 }
28
+
29
+ /** The joint whose child is this part, if any. */
30
+ export function jointOf(draft: FigureDraft | undefined, part: string): FigureJoint | undefined {
31
+ return draft?.joints?.find(joint => joint.child === part)
32
+ }
33
+
34
+ /**
35
+ * Parts a part may be attached to: every other part except itself and anything attached below it, since
36
+ * either would close a loop the format refuses (`parent-cycle`).
37
+ */
38
+ export function parentChoices(parts: readonly PartModel[], part: string): string[] {
39
+ const below = new Set<string>([part])
40
+ let grew = true
41
+ while (grew) {
42
+ grew = false
43
+ for (const one of parts) {
44
+ if (one.parent !== undefined && below.has(one.parent) && !below.has(one.name)) {
45
+ below.add(one.name)
46
+ grew = true
47
+ }
48
+ }
49
+ }
50
+ return parts.map(one => one.name).filter(name => !!name && !below.has(name))
51
+ }
52
+
53
+ function freeName(taken: Iterable<string>, stem: string): string {
54
+ const used = new Set(taken)
55
+ if (!used.has(stem)) return stem
56
+ for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
57
+ }
58
+
59
+ /** The span the driving parameter offers for a joint: its limits, or one turn either way when it has none. */
60
+ function spanOf(joint: FigureJoint): { min: number; max: number } {
61
+ return joint.limits ?? CONTINUOUS_SPAN
62
+ }
63
+
64
+ function drivingParameter(joint: FigureJoint): Parameter {
65
+ const span = spanOf(joint)
66
+ return {
67
+ name: joint.name,
68
+ range: { unit: joint.type === 'prismatic' ? 'mm' : 'deg', min: span.min, max: span.max },
69
+ default: 0,
70
+ clip: {
71
+ channels: [
72
+ {
73
+ target: joint.name,
74
+ keys: [
75
+ { at: 0, value: span.min },
76
+ { at: 1, value: span.max }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Whether a parameter is the plain one `setJointType` made for a joint: one channel on that joint, keys at
86
+ * 0 and 1 spanning the given limits. Only such a parameter follows the joint when its limits or type change;
87
+ * one the author reshaped is left as written.
88
+ */
89
+ function isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: number; max: number }): boolean {
90
+ const channels = parameter.clip?.channels ?? []
91
+ if (channels.length !== 1) return false
92
+ const [channel] = channels
93
+ if (channel!.target !== joint.name || channel!.path !== undefined) return false
94
+ const keys = channel!.keys as { at: number; value: number }[]
95
+ return (
96
+ keys.length === 2 &&
97
+ keys[0]!.at === 0 &&
98
+ keys[1]!.at === 1 &&
99
+ keys[0]!.value === span.min &&
100
+ keys[1]!.value === span.max &&
101
+ parameter.range?.min === span.min &&
102
+ parameter.range?.max === span.max
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Sets how a part moves relative to its parent, or clears the joint with `undefined`.
108
+ *
109
+ * - From none: adds a joint whose origin is the centre of the part's bottom face, with a vertical axis and
110
+ * starting limits, and a parameter that drives it over those limits.
111
+ * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel
112
+ * is removed too, because the format refuses an empty one.
113
+ * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and
114
+ * moves a plain driving parameter along with them.
115
+ */
116
+ export function setJointType(draft: FigureDraft, part: FigurePart, type: JointType | undefined): FigureDraft {
117
+ const joints = draft.joints ?? []
118
+ const existing = joints.find(joint => joint.child === part.name)
119
+
120
+ if (type === undefined) {
121
+ if (!existing) return draft
122
+ return withoutJoint(draft, existing.name)
123
+ }
124
+
125
+ if (!existing) {
126
+ const { position, size } = part.transform
127
+ const joint: FigureJoint = {
128
+ name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),
129
+ child: part.name,
130
+ type,
131
+ origin: { x: position.x, y: position.y - size.y / 2, z: position.z },
132
+ axis: { x: 0, y: 1, z: 0 }
133
+ }
134
+ if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }
135
+
136
+ const parameters = draft.parameters ?? []
137
+ const taken = [...parameters.map(one => one.name), ...(draft.animations ?? []).map(one => one.name)]
138
+ const driver = drivingParameter(joint)
139
+ driver.name = freeName(taken, joint.name)
140
+
141
+ return { ...draft, joints: [...joints, joint], parameters: [...parameters, driver] }
142
+ }
143
+
144
+ if (existing.type === type) return draft
145
+
146
+ const next: FigureJoint = { ...existing, type }
147
+ if (type === 'continuous') delete next.limits
148
+ else if (existing.type === 'continuous' || (existing.type === 'prismatic') !== (type === 'prismatic')) {
149
+ next.limits = { ...NEW_LIMITS[type] }
150
+ }
151
+
152
+ return replaceJoint(draft, existing, next)
153
+ }
154
+
155
+ /** Changes a joint's origin, axis or limits. A plain driving parameter follows new limits. */
156
+ export function patchJoint(
157
+ draft: FigureDraft,
158
+ name: string,
159
+ patch: { origin?: Vec3; axis?: Vec3; limits?: { min: number; max: number } }
160
+ ): FigureDraft {
161
+ const existing = draft.joints?.find(joint => joint.name === name)
162
+ if (!existing) return draft
163
+ const next: FigureJoint = { ...existing, ...patch }
164
+ if (existing.type === 'continuous') delete next.limits
165
+ return replaceJoint(draft, existing, next)
166
+ }
167
+
168
+ function replaceJoint(draft: FigureDraft, before: FigureJoint, after: FigureJoint): FigureDraft {
169
+ const joints = (draft.joints ?? []).map(joint => (joint === before ? after : joint))
170
+ const was = spanOf(before)
171
+ const unitChanged = (before.type === 'prismatic') !== (after.type === 'prismatic')
172
+ const spanChanged = was.min !== spanOf(after).min || was.max !== spanOf(after).max
173
+
174
+ if (!unitChanged && !spanChanged) return { ...draft, joints }
175
+
176
+ const parameters = (draft.parameters ?? []).map(parameter => {
177
+ if (!isPlainDriver(parameter, before, was)) return parameter
178
+ const driver = drivingParameter(after)
179
+ return { ...parameter, range: driver.range, clip: { ...parameter.clip, channels: driver.clip.channels } }
180
+ })
181
+ return { ...draft, joints, parameters }
182
+ }
183
+
184
+ function withoutJoint(draft: FigureDraft, name: string): FigureDraft {
185
+ const keep = <T extends { channels?: { target: string }[] }>(one: T) => (one.channels ?? []).filter(channel => channel.target !== name)
186
+
187
+ const parameters = (draft.parameters ?? [])
188
+ .map(parameter => ({ ...parameter, clip: { ...parameter.clip, channels: keep(parameter.clip) } }) as Parameter)
189
+ .filter(parameter => parameter.clip.channels.length > 0)
190
+ const animations = (draft.animations ?? [])
191
+ .map(clip => ({ ...clip, channels: keep(clip) }) as NonNullable<FigureDraft['animations']>[number])
192
+ .filter(clip => clip.channels.length > 0)
193
+ const joints = (draft.joints ?? []).filter(joint => joint.name !== name)
194
+
195
+ const next: FigureDraft = { ...draft }
196
+ if (joints.length > 0) next.joints = joints
197
+ else delete next.joints
198
+ if (draft.parameters !== undefined) next.parameters = parameters
199
+ if (draft.animations !== undefined) next.animations = animations
200
+ return next
201
+ }
@@ -15,6 +15,11 @@ export interface ProposalSession {
15
15
  picked: Set<string>
16
16
  note: string
17
17
  metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
+ /**
19
+ * The release-check fix this session was opened from, if any. The page translates the title from it;
20
+ * this module stays free of i18n because node tests import it directly.
21
+ */
22
+ fix?: FigureGateFix
18
23
  }
19
24
 
20
25
  function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
@@ -25,7 +30,7 @@ function selectedKeys(current: FigureSource | undefined, source: FigureSource):
25
30
  export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
26
31
  const source = JSON.parse(proposal.source) as FigureSource
27
32
  if (!source || !Array.isArray(source.parts)) {
28
- throw new Error('AI 후보의 Figure 형식이 올바르지 않습니다.')
33
+ throw new Error('the AI candidate is not a figure source: parts is missing or not an array')
29
34
  }
30
35
 
31
36
  return {
@@ -56,7 +61,8 @@ export function openSizingFix(current: FigureSource, fix: FigureGateFix): Propos
56
61
 
57
62
  return {
58
63
  source,
59
- title: `발행 검사 수정안 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}`,
64
+ title: '',
65
+ fix,
60
66
  picked: selectedKeys(current, source),
61
67
  note: ''
62
68
  }
@@ -14,7 +14,7 @@ import { i18next, localize } from '@operato/i18n'
14
14
  import { navigate, PageView } from '@operato/shell'
15
15
  import { openOverlay } from '@operato/layout'
16
16
  import { requestFigureAI, consumePendingFigureProposal } from '../modeller/figure-ai-target.js'
17
- import { validate } from '@hatiolab/figure-model'
17
+ import { isPlaceholderType, placeholderType, TYPE_LENGTH, TYPE_PATTERN, validate } from '@hatiolab/figure-model'
18
18
  import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-model'
19
19
 
20
20
  import * as edits from '../modeller/part-edits.js'
@@ -114,6 +114,20 @@ function startingModel(type: string): { draft: FigureDraft; parts: PartModel[] }
114
114
  * 바꾸는 것은 이 페이지다. 두 곳에서 고치면 실행 취소도 저장도 어느 쪽이 맞는지 알 수
115
115
  * 없게 된다.
116
116
  */
117
+ /**
118
+ * Whether this name could be released, by the format's own rules (`type-not-identifier`,
119
+ * `type-is-placeholder`). Saving is not blocked, but the type cannot change after creation, so the
120
+ * author has to hear it now to avoid a draft that can never be released.
121
+ */
122
+ function releasableType(type: string): boolean {
123
+ return (
124
+ !isPlaceholderType(type) &&
125
+ TYPE_PATTERN.test(type) &&
126
+ type.length >= TYPE_LENGTH.min &&
127
+ type.length <= TYPE_LENGTH.max
128
+ )
129
+ }
130
+
117
131
  @customElement('figure-modeller-page')
118
132
  export class FigureModellerPage extends FigureModellerPageBase {
119
133
  static styles = css`
@@ -1112,10 +1126,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1112
1126
 
1113
1127
  return html`
1114
1128
  <div decide>
1115
- <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
1129
+ <span>${this.proposalTitle(this.proposalSession)}</span>
1116
1130
  <div spacer></div>
1117
1131
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
1118
- ${stale ? html`<span role="alert">원본이 변경되었습니다. 후보를 취소하고 AI 지원에서 다시 요청하세요.</span>` : ''}
1132
+ ${stale ? html`<span role="alert">${i18next.t('figure.text.source-changed-ask-again')}</span>` : ''}
1119
1133
  <button take ?disabled=${stale || errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
1120
1134
  ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
1121
1135
  </button>
@@ -1434,7 +1448,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1434
1448
  this.proposalFeedback = []
1435
1449
  this.mode = 'edit'
1436
1450
  // 타입 이름을 사람이 정하기 전까지는 임시 이름을 쓴다. 저장할 때 확정한다.
1437
- const draftType = `FIGURE_${Date.now().toString(36).toUpperCase()}`
1451
+ const draftType = placeholderType()
1438
1452
  const { draft, parts } = startingModel(draftType)
1439
1453
 
1440
1454
  this.figure = { id: '', type: draftType, name: '' } as Figure
@@ -1719,8 +1733,12 @@ export class FigureModellerPage extends FigureModellerPageBase {
1719
1733
  if (!this.figure.id) {
1720
1734
  // 신규 저장: 타입 코드와 표시 이름을 명확히 팝업으로 확인/입력받음
1721
1735
  let defaultType = this.draft?.figureType || this.figure.type || ''
1722
- if (!defaultType || defaultType.startsWith('FIGURE_')) {
1723
- defaultType = 'FIGURE'
1736
+ /*
1737
+ A placeholder is not prefilled. This used to prefill 'FIGURE', inviting the author to confirm a
1738
+ meaningless name that cannot change later and that the release gate refuses.
1739
+ */
1740
+ if (!defaultType || isPlaceholderType(defaultType)) {
1741
+ defaultType = ''
1724
1742
  }
1725
1743
  this.saveModalType = defaultType
1726
1744
  this.saveModalName = this.figure.name || ''
@@ -1745,7 +1763,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1745
1763
  const nameToSave = (this.saveModalName || this.figure.name || typeToSave).trim()
1746
1764
 
1747
1765
  if (!typeToSave) {
1748
- throw new Error('타입 이름을 입력하세요.')
1766
+ throw new Error(i18next.t('figure.text.enter-a-type-name'))
1749
1767
  }
1750
1768
 
1751
1769
  const taken = await fetchFigureTypeNames()
@@ -1803,29 +1821,46 @@ export class FigureModellerPage extends FigureModellerPageBase {
1803
1821
  }
1804
1822
  }
1805
1823
 
1824
+ /** Title of the candidate bar. A candidate opened from a release-check fix is described by translating that fix here. */
1825
+ private proposalTitle(session: proposalSessions.ProposalSession): string {
1826
+ const fix = session.fix
1827
+ if (fix) {
1828
+ return i18next.t('figure.text.release-check-fix', {
1829
+ part: fix.part,
1830
+ axis: fix.axis.toUpperCase(),
1831
+ from: fix.from ?? 'auto',
1832
+ to: fix.to
1833
+ })
1834
+ }
1835
+ return session.title || i18next.t('figure.text.assistant-proposed-a-figure')
1836
+ }
1837
+
1806
1838
  private renderSaveModal() {
1807
1839
  if (!this.showSaveModal) return nothing
1808
1840
 
1809
1841
  return html`
1810
1842
  <div class="save-modal-backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this.showSaveModal = false }}>
1811
1843
  <div class="save-modal-card" role="dialog" aria-modal="true">
1812
- <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure', { defaultValue: '도형 초안 저장' })}</h3>
1813
- <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc', { defaultValue: '도면과 캔버스에서 식별할 고유 타입 코드와 이름을 정합니다.' })}</p>
1844
+ <h3 class="save-modal-title">${i18next.t('figure.title.save-new-figure')}</h3>
1845
+ <p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc')}</p>
1814
1846
 
1815
1847
  <div class="save-modal-field">
1816
- <label>${i18next.t('figure.label.figure-type', { defaultValue: '타입 코드 (영문 대문자)' })}</label>
1848
+ <label>${i18next.t('figure.label.figure-type')}</label>
1817
1849
  <input
1818
1850
  .value=${this.saveModalType}
1819
- placeholder="예: OHT, AGV_LIFTER"
1851
+ placeholder=${i18next.t('figure.text.figure-type-example')}
1820
1852
  @input=${(e: Event) => (this.saveModalType = (e.target as HTMLInputElement).value.trim().toUpperCase())}
1821
1853
  />
1854
+ ${this.saveModalType && !releasableType(this.saveModalType)
1855
+ ? html`<div class="save-modal-error">${i18next.t('figure.text.type-name-will-not-release')}</div>`
1856
+ : nothing}
1822
1857
  </div>
1823
1858
 
1824
1859
  <div class="save-modal-field">
1825
- <label>${i18next.t('figure.label.name', { defaultValue: '도형 이름' })}</label>
1860
+ <label>${i18next.t('figure.label.name')}</label>
1826
1861
  <input
1827
1862
  .value=${this.saveModalName}
1828
- placeholder="예: 오버헤드 호이스트 트랜스포트"
1863
+ placeholder=${i18next.t('figure.text.figure-name-example')}
1829
1864
  @input=${(e: Event) => (this.saveModalName = (e.target as HTMLInputElement).value)}
1830
1865
  />
1831
1866
  </div>
@@ -1834,10 +1869,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
1834
1869
 
1835
1870
  <div class="save-modal-actions">
1836
1871
  <button class="btn-cancel" @click=${() => (this.showSaveModal = false)}>
1837
- ${i18next.t('figure.button.cancel', { defaultValue: '취소' })}
1872
+ ${i18next.t('figure.button.cancel')}
1838
1873
  </button>
1839
1874
  <button class="btn-confirm" ?disabled=${this.saving || !this.saveModalType} @click=${() => this.doSave()}>
1840
- ${this.saving ? i18next.t('figure.text.saving-figure', { defaultValue: '저장 중...' }) : i18next.t('figure.button.save', { defaultValue: '저장' })}
1875
+ ${this.saving ? i18next.t('figure.text.saving-figure') : i18next.t('figure.button.save')}
1841
1876
  </button>
1842
1877
  </div>
1843
1878
  </div>
@@ -1,4 +1,5 @@
1
1
  import gql from 'graphql-tag';
2
+ import { i18next } from '@operato/i18n';
2
3
  import { client } from '@operato/graphql';
3
4
  /**
4
5
  * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**
@@ -16,7 +17,9 @@ function unwrap(response, field) {
16
17
  if (said)
17
18
  throw new Error(said);
18
19
  if (!response.data) {
19
- throw new Error(`서버가 ${field} 돌려주지 않았습니다.`);
20
+ /* The field name stays off the screen because the user cannot act on it; it goes to the console. */
21
+ console.error(`[figure-ui] the server returned no data for ${field}`);
22
+ throw new Error(i18next.t('figure.text.server-returned-no-data'));
20
23
  }
21
24
  return response.data[field];
22
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n throw new Error(`서버가 ${field} 를 돌려주지 않았습니다.`)\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request },\n // @operato/graphql 는 이 신호가 있을 때만 Upload가 포함된 변수를 multipart로 인코딩한다.\n // 없으면 브라우저 File은 JSON에서 {}가 되어 서버 Upload scalar가 요청 자체를 거절한다.\n context: { hasUpload: !!request.image }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}