@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.
- package/client/graphql/index.ts +4 -1
- package/client/modeller/figure-animations.ts +229 -29
- package/client/modeller/figure-history-panel.ts +2 -2
- package/client/modeller/figure-inspector.ts +151 -5
- package/client/modeller/figure-preview.ts +5 -5
- package/client/modeller/figure-source.ts +33 -45
- package/client/modeller/figure-view.ts +20 -0
- package/client/modeller/joint-edits.ts +201 -0
- package/client/modeller/proposal-session.ts +8 -2
- package/client/pages/figure-modeller-page.ts +50 -15
- package/dist-client/graphql/index.js +4 -1
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-animations.d.ts +19 -0
- package/dist-client/modeller/figure-animations.js +206 -22
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +2 -2
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +11 -0
- package/dist-client/modeller/figure-inspector.js +124 -4
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +5 -5
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +12 -1
- package/dist-client/modeller/figure-source.js +13 -40
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +16 -0
- package/dist-client/modeller/figure-view.js +15 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/joint-edits.d.ts +29 -0
- package/dist-client/modeller/joint-edits.js +175 -0
- package/dist-client/modeller/joint-edits.js.map +1 -0
- package/dist-client/modeller/proposal-session.d.ts +5 -0
- package/dist-client/modeller/proposal-session.js +3 -2
- package/dist-client/modeller/proposal-session.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +2 -22
- package/dist-client/pages/figure-modeller-page.js +46 -15
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/test/ai-proposal-contract.test.ts +3 -2
- package/test/figure-source.test.ts +43 -57
- package/test/i18n-prefix-guard.test.ts +2 -3
- package/test/joint-edits.test.ts +130 -0
- package/test/preview-board-depth.test.ts +27 -0
- package/translations/en.json +119 -80
- package/translations/ja.json +208 -58
- package/translations/ko.json +139 -100
- package/translations/ms.json +206 -56
- package/translations/zh.json +207 -57
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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.
|
|
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"
|
|
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 =
|
|
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
|
-
|
|
1723
|
-
|
|
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'
|
|
1813
|
-
<p class="save-modal-desc">${i18next.t('figure.text.save-new-figure-desc'
|
|
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'
|
|
1848
|
+
<label>${i18next.t('figure.label.figure-type')}</label>
|
|
1817
1849
|
<input
|
|
1818
1850
|
.value=${this.saveModalType}
|
|
1819
|
-
placeholder
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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"]}
|