@things-factory/figure-service 10.1.37 → 10.1.39
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/dist-server/service/figure/figure-format.d.ts +48 -0
- package/dist-server/service/figure/figure-format.js +250 -0
- package/dist-server/service/figure/figure-format.js.map +1 -0
- package/dist-server/service/figure/figure-mutation.d.ts +1 -1
- package/dist-server/service/figure/figure-mutation.js +19 -11
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +9 -0
- package/dist-server/service/figure/figure-query.js +53 -6
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +8 -0
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +11 -7
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-v3-write.d.ts +17 -1
- package/dist-server/service/figure/figure-v3-write.js +21 -4
- package/dist-server/service/figure/figure-v3-write.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/figure/figure-format.test.ts +289 -0
- package/server/service/figure/figure-format.ts +267 -0
- package/server/service/figure/figure-inspect-db.test.ts +130 -0
- package/server/service/figure/figure-mutation.ts +19 -11
- package/server/service/figure/figure-propose.test.ts +8 -1
- package/server/service/figure/figure-query.ts +51 -5
- package/server/service/figure/figure-rename.test.ts +27 -0
- package/server/service/figure/figure-rename.ts +11 -2
- package/server/service/figure/figure-tool-doors.test.ts +13 -13
- package/server/service/figure/figure-tools.ts +11 -7
- package/server/service/figure/figure-v3-write.ts +21 -5
- package/translations/en.json +23 -1
- package/translations/ko.json +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.39",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"migration:create": "node ../../node_modules/typeorm/cli.js migration:create ./server/migrations/migration"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hatiolab/figure-model": "^0.1.
|
|
26
|
+
"@hatiolab/figure-model": "^0.1.38",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.39",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.39",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.39",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
|
|
37
37
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figure-format — 한 문이 두 판을 읽는다.
|
|
3
|
+
*
|
|
4
|
+
* 전환 중에는 `source` 칸에 V2 정본과 V3 자산이 섞여 있다(ADR-0087). 저장·발행·되돌리기·소비처가
|
|
5
|
+
* 저마다 판을 따지면 네 자리에서 답이 갈린다. 여기서 한 번 묻는다.
|
|
6
|
+
*
|
|
7
|
+
* 이 파일이 지키는 것:
|
|
8
|
+
* - 판은 자산이 적은 `version` 으로 안다. 생김새로 짐작하지 않는다.
|
|
9
|
+
* - 판을 모르는 정본은 거절한다 — 알아보지 못하는 것을 저장하면 안 된다.
|
|
10
|
+
* - V3 자산도 저장·발행·되돌리기·소비처를 지난다. 이 넷은 전에 V2 만 지날 수 있었다.
|
|
11
|
+
* - 예산 칸(`triangles`·`groups`)은 두 판에서 같은 뜻이다.
|
|
12
|
+
*/
|
|
13
|
+
import { createV3Asset, addV3Box, addV3Primitive, serializeV3Asset, applyV3Authoring, proposeV3Occupancy, placeholderType } from '@hatiolab/figure-model'
|
|
14
|
+
import type { V3Asset } from '@hatiolab/figure-model'
|
|
15
|
+
|
|
16
|
+
import { consumablePayloadOf, formatOf, inspectAnySource, parseAnySource } from './figure-format'
|
|
17
|
+
import { FigureSourceError } from './figure-source'
|
|
18
|
+
|
|
19
|
+
/** 온전한 V2 정본 하나. 바닥에 선 상자 한 부품. */
|
|
20
|
+
const v2Source = (over: Record<string, unknown> = {}) =>
|
|
21
|
+
JSON.stringify({
|
|
22
|
+
version: 2,
|
|
23
|
+
type: 'MIXER',
|
|
24
|
+
base: { x: 300, y: 800, z: 300 },
|
|
25
|
+
detailLevel: 'M',
|
|
26
|
+
parts: [
|
|
27
|
+
{
|
|
28
|
+
name: 'body',
|
|
29
|
+
primitive: 'cube',
|
|
30
|
+
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
31
|
+
material: { token: 'palette.primary' }
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
...over
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** 온전한 V3 자산 하나: 바닥에 선 상자와, 부품에서 제안받아 확정한 점유 부피. */
|
|
38
|
+
function v3Asset(): V3Asset {
|
|
39
|
+
let asset = createV3Asset('Mixer')
|
|
40
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
41
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
42
|
+
asset = applyV3Authoring(asset, {
|
|
43
|
+
kind: 'declare-occupancy',
|
|
44
|
+
proposal: proposeV3Occupancy(asset, { over: 'range' }),
|
|
45
|
+
placement: 'floor'
|
|
46
|
+
})
|
|
47
|
+
return asset
|
|
48
|
+
}
|
|
49
|
+
const v3Source = () => serializeV3Asset(v3Asset())
|
|
50
|
+
|
|
51
|
+
describe('figure-format — 판을 알아보는 문', () => {
|
|
52
|
+
describe('판은 자산이 적는다', () => {
|
|
53
|
+
it('숫자 version 은 V2, figure-v3-asset-1 은 V3 다', () => {
|
|
54
|
+
expect(formatOf(v2Source())).toBe('v2')
|
|
55
|
+
expect(formatOf(v3Source())).toBe('v3')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('판을 안 적은 정본은 거절한다 — 생김새로 짐작하지 않는다', () => {
|
|
59
|
+
expect(() => formatOf(JSON.stringify({ type: 'MIXER', parts: [] }))).toThrow(FigureSourceError)
|
|
60
|
+
try {
|
|
61
|
+
formatOf(JSON.stringify({ type: 'MIXER' }))
|
|
62
|
+
} catch (e) {
|
|
63
|
+
expect((e as FigureSourceError).findings[0].code).toBe('source-format-unknown')
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('JSON 이 아니면 그 말로 거절한다', () => {
|
|
68
|
+
try {
|
|
69
|
+
formatOf('{')
|
|
70
|
+
} catch (e) {
|
|
71
|
+
expect((e as FigureSourceError).findings[0].code).toBe('source-not-json')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('저장 — 두 판 다 지난다', () => {
|
|
77
|
+
it('V2 정본은 전과 같이 점수·삼각형·묶음을 받는다', () => {
|
|
78
|
+
const parsed = parseAnySource(v2Source())
|
|
79
|
+
expect(parsed.format).toBe('v2')
|
|
80
|
+
expect(parsed.derived.triangles).toBe(12)
|
|
81
|
+
expect(parsed.derived.groups).toBeGreaterThan(0)
|
|
82
|
+
expect(typeof parsed.derived.score).toBe('number')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('V3 자산은 같은 예산 칸을 받는다 — 상자 하나는 두 판에서 다 삼각형 12 다', () => {
|
|
86
|
+
const parsed = parseAnySource(v3Source())
|
|
87
|
+
expect(parsed.format).toBe('v3')
|
|
88
|
+
expect(parsed.derived.triangles).toBe(12)
|
|
89
|
+
expect(parsed.derived.groups).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('V3 의 점수 칸은 비운다 — V2 점수는 V2 재사용의 잣대이고 V3 를 잰 적이 없다', () => {
|
|
93
|
+
expect(parseAnySource(v3Source()).derived.score).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('형식을 어긴 V3 자산은 거절한다', () => {
|
|
97
|
+
const broken = JSON.parse(v3Source())
|
|
98
|
+
broken.appearance = []
|
|
99
|
+
expect(() => parseAnySource(JSON.stringify(broken))).toThrow(FigureSourceError)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('발행 판정 — 두 판 다 지난다', () => {
|
|
104
|
+
it('제 부피 안에 든 V3 자산은 막지 않는다', () => {
|
|
105
|
+
const inspection = inspectAnySource(v3Source())
|
|
106
|
+
expect(inspection.blocked).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('선언한 부피 밖으로 나간 부품은 이름과 함께 막는다', () => {
|
|
110
|
+
let asset = v3Asset()
|
|
111
|
+
asset = addV3Box(asset, { id: 'arm', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
|
|
112
|
+
const inspection = inspectAnySource(serializeV3Asset(asset))
|
|
113
|
+
expect(inspection.blocked).toBe(true)
|
|
114
|
+
expect(inspection.findings.some(f => f.code === 'part-outside-occupancy' && f.at === 'arm')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('부피를 선언하지 않은 V3 자산은 막는다', () => {
|
|
118
|
+
let asset = createV3Asset('Bare')
|
|
119
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
120
|
+
const inspection = inspectAnySource(serializeV3Asset(asset))
|
|
121
|
+
expect(inspection.blocked).toBe(true)
|
|
122
|
+
expect(inspection.findings.some(f => f.code === 'occupancy-undeclared')).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('전 범위를 증명하지 못한 판정은 막지 않고 그 사실을 말한다', () => {
|
|
126
|
+
/* 상태에 따라 도는 부품은 모서리만으로 증명되지 않는다 — 표본 검사였음을 발행 화면이 읽는다. */
|
|
127
|
+
let asset = v3Asset()
|
|
128
|
+
asset = addV3Primitive(asset, { id: 'wheel', kind: 'cylinder', dimensions: { radius: 20, length: 20 }, position: { x: 0, y: 20, z: 0 } })
|
|
129
|
+
const m = asset.document.model as any
|
|
130
|
+
m.inputs.push({ id: 'spin', unit: 'deg', min: -180, max: 180, role: 'state' })
|
|
131
|
+
asset.stateDefaults['spin'] = 0
|
|
132
|
+
m.constants.push({ id: 'axisY0', unit: 'ratio', value: 0 }, { id: 'axisY1', unit: 'ratio', value: 1 })
|
|
133
|
+
m.nodes.push({
|
|
134
|
+
id: 'wheel.turn',
|
|
135
|
+
op: 'axis-turn@1',
|
|
136
|
+
args: ['axisY0', 'axisY1', 'axisY0', 'spin'],
|
|
137
|
+
outputs: { pose: 'wheel.turn.p' },
|
|
138
|
+
params: { from: 'wheel.spun', to: 'asset' }
|
|
139
|
+
})
|
|
140
|
+
const pose = m.nodes.find((n: any) => n.id === 'wheel.pose')
|
|
141
|
+
pose.params = { ...pose.params, to: 'wheel.spun' }
|
|
142
|
+
m.nodes.push({ id: 'wheel.world', op: 'compose@1', args: ['wheel.turn.p', pose.outputs.pose], outputs: { pose: 'wheel.wp' } })
|
|
143
|
+
m.nodes.find((n: any) => n.id === 'wheel').args = [m.nodes.find((n: any) => n.id === 'wheel.shape').outputs.shape, 'wheel.wp']
|
|
144
|
+
asset = applyV3Authoring(asset, {
|
|
145
|
+
kind: 'declare-occupancy',
|
|
146
|
+
proposal: proposeV3Occupancy(asset, { over: 'range' }),
|
|
147
|
+
placement: 'floor'
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const inspection = inspectAnySource(serializeV3Asset(asset))
|
|
151
|
+
const sampled = inspection.findings.find(f => f.code === 'state-range-sampled')
|
|
152
|
+
expect(sampled).toBeDefined()
|
|
153
|
+
expect(sampled!.blocking).toBe(false)
|
|
154
|
+
/* 증명을 막은 연산은 기술 목록이라 문장이 아니라 자리 칸에 간다. */
|
|
155
|
+
expect(sampled!.at).toContain('axis-turn@1')
|
|
156
|
+
expect(sampled!.why).not.toContain('axis-turn@1')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('정본이 없으면 전과 같이 그 말로 막는다', () => {
|
|
160
|
+
expect(inspectAnySource(null).findings[0].code).toBe('no-source')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('직전 발행판이 있는 V3 재발행은 견주지 못했다고 말한다 — 막지는 않는다', () => {
|
|
164
|
+
/* V2 는 지난 발행과 견주어 놓인 인스턴스를 깨는 변경을 잡는다. V3 는 그 비교가 아직 없으니 그 사실을 적는다. */
|
|
165
|
+
const again = inspectAnySource(v3Source(), v3Source())
|
|
166
|
+
const warning = again.findings.find(f => f.code === 'released-unreadable')
|
|
167
|
+
expect(warning).toBeDefined()
|
|
168
|
+
expect(warning!.blocking).toBe(false)
|
|
169
|
+
expect(again.blocked).toBe(false)
|
|
170
|
+
|
|
171
|
+
/* 첫 발행에는 견줄 것이 없으니 그 말도 없다. */
|
|
172
|
+
expect(inspectAnySource(v3Source()).findings.some(f => f.code === 'released-unreadable')).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
ADR-0087 5단계에서 V3 판정이 저작자가 보는 유일한 발행 판정이 된다. 그때까지 이 판정은 발행 패널에 영어 문장
|
|
178
|
+
(`detail`)을 그대로 냈고 왜 · 어떻게가 없었다 — 사용자가 2026-09-19 에 「가장 큰 문제」로 짚고 V2 에서 고친 바로
|
|
179
|
+
그 모양이다. 문장은 요청의 언어를 아는 서버가 코드와 값에서 만든다(ADR-0054).
|
|
180
|
+
*/
|
|
181
|
+
describe('V3 판정의 문장 — 발행 패널의 말로', () => {
|
|
182
|
+
const KO = require('../../../translations/ko.json') as Record<string, string>
|
|
183
|
+
const EN = require('../../../translations/en.json') as Record<string, string>
|
|
184
|
+
|
|
185
|
+
/** i18next 처럼 — 키가 있으면 `{이름}` 을 채우고, 없으면 defaultValue. */
|
|
186
|
+
const translatorOf = (dict: Record<string, string>, asked: string[] = []) =>
|
|
187
|
+
(key: string, options: Record<string, unknown> = {}) => {
|
|
188
|
+
asked.push(key)
|
|
189
|
+
const text = dict[key] ?? (options.defaultValue as string)
|
|
190
|
+
return text.replace(/\{(\w+)\}/g, (_, name) => String(options[name] ?? `{${name}}`))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const outside = () => {
|
|
194
|
+
let asset = v3Asset()
|
|
195
|
+
asset = addV3Box(asset, { id: 'arm', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
|
|
196
|
+
return serializeV3Asset(asset)
|
|
197
|
+
}
|
|
198
|
+
const bare = () => {
|
|
199
|
+
let asset = createV3Asset('Bare')
|
|
200
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
201
|
+
return serializeV3Asset(asset)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
it('넘은 부품은 이름 · 축 · 넘은 양을 화면 말투로 말하고, 왜와 어떻게를 함께 낸다', () => {
|
|
205
|
+
const finding = inspectAnySource(outside(), null, translatorOf(KO)).findings.find(f => f.code === 'part-outside-occupancy')!
|
|
206
|
+
|
|
207
|
+
expect(finding.message).toMatch(/^'arm' 부품이 공간 선언을 X 로 [\d.]+mm 넘습니다\.$/)
|
|
208
|
+
expect(finding.why).toMatch(/니다\.$/)
|
|
209
|
+
expect(finding.how).toContain('부품에 맞추기')
|
|
210
|
+
expect(finding.how).toMatch(/세요\.$/)
|
|
211
|
+
/* 영어 로그 문장이 화면으로 새지 않는다. */
|
|
212
|
+
expect(`${finding.message}${finding.why}${finding.how}`).not.toMatch(/declared volume|leaves/)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('공간을 선언하지 않은 도형은 무엇을 누르면 되는지 말한다', () => {
|
|
216
|
+
const finding = inspectAnySource(bare(), null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
|
|
217
|
+
|
|
218
|
+
expect(finding.message).toBe('이 도형은 차지하는 공간을 아직 선언하지 않았습니다.')
|
|
219
|
+
expect(finding.how).toContain('부품에 맞추기')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('부품이 없는 새 도형에는 도형을 먼저 추가하라고 한다 — 화면은 그때 맞추기 단추를 내지 않는다', () => {
|
|
223
|
+
const empty = serializeV3Asset(createV3Asset('Empty'))
|
|
224
|
+
const finding = inspectAnySource(empty, null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
|
|
225
|
+
|
|
226
|
+
expect(finding.how).toBe('먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.')
|
|
227
|
+
expect(inspectAnySource(empty).findings.find(f => f.code === 'occupancy-undeclared')!.how).toMatch(/^Add a shape first/)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('번역이 없으면 영어로 답한다 — 키를 찍지 않는다', () => {
|
|
231
|
+
const finding = inspectAnySource(outside()).findings.find(f => f.code === 'part-outside-occupancy')!
|
|
232
|
+
|
|
233
|
+
expect(finding.message).toMatch(/^'arm' goes [\d.]+ mm past the declared space on X\.$/)
|
|
234
|
+
expect(finding.message).not.toContain('figure.gate.')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
V3 자산에는 type 이 없어 V2 의 임시 이름 검사가 돌지 않았고, 새 V3 도형이 `FIGURE_<uuid>` 로 발행됐다
|
|
239
|
+
(:3300, 2026-09-24). 발행하면 이름이 굳으므로 행의 type 으로 같은 거절을 한다.
|
|
240
|
+
*/
|
|
241
|
+
it('임시 이름인 V3 도형은 발행을 막는다 — 이름은 행에 있다', () => {
|
|
242
|
+
/* The name a new V3 figure gets — the format's rule, the one the gate recognises. */
|
|
243
|
+
const placeholder = placeholderType()
|
|
244
|
+
const finding = inspectAnySource(v3Source(), null, translatorOf(KO), placeholder).findings.find(f => f.code === 'type-is-placeholder')
|
|
245
|
+
|
|
246
|
+
expect(finding?.blocking).toBe(true)
|
|
247
|
+
expect(finding!.message).toBe(`'${placeholder}' 은 처음 저장할 때 붙은 임시 타입입니다.`)
|
|
248
|
+
/* 화면의 칸 이름은 「타입」이다(`figure.label.figure-type`). */
|
|
249
|
+
expect(finding!.how).toContain('타입 칸')
|
|
250
|
+
expect(inspectAnySource(v3Source(), null, translatorOf(KO), 'ROLLER_CONVEYOR').blocked).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
/* 코드가 묻는 키가 두 언어에 다 있어야 한다 — 없으면 그 언어의 화면에 영어가 섞인다. */
|
|
254
|
+
it('판정이 묻는 키는 ko · en 에 다 있다', () => {
|
|
255
|
+
const asked: string[] = []
|
|
256
|
+
const t = translatorOf(KO, asked)
|
|
257
|
+
inspectAnySource(outside(), null, t)
|
|
258
|
+
inspectAnySource(bare(), null, t)
|
|
259
|
+
inspectAnySource(v3Source(), v3Source(), t)
|
|
260
|
+
inspectAnySource(v3Source(), null, t, 'FIGURE_ABCDEF12')
|
|
261
|
+
|
|
262
|
+
const keys = [...new Set(asked)]
|
|
263
|
+
expect(keys.length).toBeGreaterThan(5)
|
|
264
|
+
expect(keys.filter(k => !(k in KO))).toEqual([])
|
|
265
|
+
expect(keys.filter(k => !(k in EN))).toEqual([])
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('소비처 — 판마다 받을 것이 다르다', () => {
|
|
270
|
+
it('V2 는 전과 같이 청사진을 받는다', () => {
|
|
271
|
+
const payload = consumablePayloadOf(v2Source())
|
|
272
|
+
expect(payload.format).toBe('v2')
|
|
273
|
+
expect((payload.blueprint as { groups?: unknown[] }).groups!.length).toBeGreaterThan(0)
|
|
274
|
+
expect((payload.blueprint as Record<string, unknown>).format).toBeUndefined()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('V3 는 자산을 그대로 받는다 — 그리는 쪽이 커널로 컴파일한다', () => {
|
|
278
|
+
const payload = consumablePayloadOf(v3Source())
|
|
279
|
+
expect(payload.format).toBe('v3')
|
|
280
|
+
expect((payload.asset as V3Asset).version).toBe('figure-v3-asset-1')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('컴파일되지 않는 V3 자산은 소비처에 넘기지 않고 던진다', () => {
|
|
284
|
+
const broken = JSON.parse(v3Source())
|
|
285
|
+
broken.designInputs = {}
|
|
286
|
+
expect(() => consumablePayloadOf(JSON.stringify(broken))).toThrow()
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which format a stored source is written in, and one door per job that reads either.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* The cutover (ADR-0087) converts assets to V3 and the `source` column holds one or the other while that
|
|
7
|
+
* happens. Every server path that reads a source — saving, publishing, restoring a version, handing a
|
|
8
|
+
* blueprint to a board — had exactly one implementation, the V2 one, so a V3 asset saved through
|
|
9
|
+
* `saveFigureV3` could not be published, restored or drawn. Branching at each of those four places would put
|
|
10
|
+
* the same question in four voices; it is asked once, here.
|
|
11
|
+
*
|
|
12
|
+
* ## How the format is known
|
|
13
|
+
*
|
|
14
|
+
* The asset says so. A V2 source carries `version: 2`; a V3 asset carries `version: 'figure-v3-asset-1'`.
|
|
15
|
+
* Nothing is guessed from the shape of the object: a source that says neither is refused by name, because a
|
|
16
|
+
* source the server cannot identify is one it must not save.
|
|
17
|
+
*
|
|
18
|
+
* ## What is not here
|
|
19
|
+
*
|
|
20
|
+
* No conversion. This door reads what is stored; turning a V2 asset into a V3 one is `convertV2ToV3`, run
|
|
21
|
+
* deliberately against a recoverable copy (the designer's step 3), never as a side effect of a save.
|
|
22
|
+
*/
|
|
23
|
+
import { compile, compileV3Asset, inspectV3Asset, isPlaceholderType, parseV3Asset, serializeV3Asset, v3CostOf } from '@hatiolab/figure-model'
|
|
24
|
+
import type { V3ContractError, V3Violation } from '@hatiolab/figure-model'
|
|
25
|
+
|
|
26
|
+
import type { GateFinding, Inspection } from './figure-gate.js'
|
|
27
|
+
import { inspectSource } from './figure-gate.js'
|
|
28
|
+
import { FigureSourceError, parseSource } from './figure-source.js'
|
|
29
|
+
import type { ParsedSource } from './figure-source.js'
|
|
30
|
+
|
|
31
|
+
export type FigureFormat = 'v2' | 'v3'
|
|
32
|
+
|
|
33
|
+
/** The version each format stamps on its own source. */
|
|
34
|
+
export const V3_ASSET_VERSION = 'figure-v3-asset-1'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which format this source is written in, read from the `version` the asset carries.
|
|
38
|
+
*
|
|
39
|
+
* @throws {FigureSourceError} when the text is not JSON, or carries no version this server knows.
|
|
40
|
+
*/
|
|
41
|
+
export function formatOf(sourceJson: string): FigureFormat {
|
|
42
|
+
let parsed: unknown
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(sourceJson)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new FigureSourceError([
|
|
47
|
+
{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, at: 'source' }
|
|
48
|
+
])
|
|
49
|
+
}
|
|
50
|
+
const version = (parsed as { version?: unknown } | null)?.version
|
|
51
|
+
if (version === V3_ASSET_VERSION) return 'v3'
|
|
52
|
+
if (typeof version === 'number') return 'v2'
|
|
53
|
+
throw new FigureSourceError([
|
|
54
|
+
{
|
|
55
|
+
code: 'source-format-unknown',
|
|
56
|
+
message: `정본의 판을 알 수 없다 — V2 는 숫자 version 을, V3 자산은 '${V3_ASSET_VERSION}' 을 적는다`,
|
|
57
|
+
at: 'version'
|
|
58
|
+
}
|
|
59
|
+
])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validates a source of either format and computes what the catalogue shows.
|
|
64
|
+
*
|
|
65
|
+
* A V3 asset is measured with `v3CostOf`, which charges the same per-shape estimate V2 charges, so the
|
|
66
|
+
* budget columns mean one thing across the two formats. `score` is left null for V3: the V2 score is a
|
|
67
|
+
* measure of V2 reuse and carrying it over would put a number in the column that nothing computed.
|
|
68
|
+
*
|
|
69
|
+
* @throws {FigureSourceError} when the source breaks its own format.
|
|
70
|
+
*/
|
|
71
|
+
export function parseAnySource(sourceJson: string): ParsedSource & { format: FigureFormat } {
|
|
72
|
+
if (formatOf(sourceJson) === 'v2') return { ...parseSource(sourceJson), format: 'v2' }
|
|
73
|
+
|
|
74
|
+
let asset
|
|
75
|
+
try {
|
|
76
|
+
asset = parseV3Asset(sourceJson)
|
|
77
|
+
compileV3Asset(asset)
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const error = e as V3ContractError & { path?: string }
|
|
80
|
+
throw new FigureSourceError([
|
|
81
|
+
{ code: error.code ?? 'v3-asset-invalid', message: error.message ?? String(e), at: error.path }
|
|
82
|
+
])
|
|
83
|
+
}
|
|
84
|
+
const cost = v3CostOf(asset)
|
|
85
|
+
return {
|
|
86
|
+
format: 'v3',
|
|
87
|
+
source: serializeV3Asset(asset),
|
|
88
|
+
derived: { score: null as unknown as number, triangles: cost.triangles, groups: cost.groups },
|
|
89
|
+
violations: []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether this source may be published, and why not.
|
|
95
|
+
*
|
|
96
|
+
* For V3 the judgement is `inspectV3Asset`: every part inside the volume the asset declares, over the whole
|
|
97
|
+
* state range, and the face it says it is mounted on lying in that plane. A verdict reached by sampling is
|
|
98
|
+
* reported as a finding of its own rather than hidden — an author releasing a figure the gate could only
|
|
99
|
+
* sample should be told so, because nothing proved it holds between the states that were checked.
|
|
100
|
+
*/
|
|
101
|
+
/** The request's translator. Always called with params — `t(key, undefined)` loses the locale. */
|
|
102
|
+
export type Translate = (key: string, options?: Record<string, unknown>) => string
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A V3 finding in the words of the release panel.
|
|
106
|
+
*
|
|
107
|
+
* The gate says what it measured as a code and values (`part`, `axis`, `mm`, `degrees`) and an English `detail`
|
|
108
|
+
* for logs. The sentence an author reads is made here, where the request's language is known (ADR-0054) — not in
|
|
109
|
+
* figure-model, which knows no locale, and not on the screen, which would then have a second account of the same
|
|
110
|
+
* refusal. The English line is the fallback when a key is missing, so a missing translation reads as English
|
|
111
|
+
* rather than as the key printed raw.
|
|
112
|
+
*
|
|
113
|
+
* The words are the modeller's: 「공간 선언」 for the volume a figure takes up and 「장착면」 for the face it stands on
|
|
114
|
+
* (`figure.text.mounting-face-explained`). The same register as the V2 blockers since 2026-09-19 — a fact, then
|
|
115
|
+
* the way out — because at ADR-0087 step 5 these become the only findings an author sees.
|
|
116
|
+
*/
|
|
117
|
+
function v3Wording(violation: V3Violation, say: Say, hasParts: boolean): Pick<GateFinding, 'message' | 'why' | 'how'> {
|
|
118
|
+
const part = violation.part ?? ''
|
|
119
|
+
const axis = (violation.axis ?? '').toUpperCase()
|
|
120
|
+
const mm = violation.mm ?? 0
|
|
121
|
+
const degrees = violation.degrees ?? 0
|
|
122
|
+
const key = (field: string) => `figure.gate.${violation.code.toLowerCase().replace(/_/g, '-')}.${field}`
|
|
123
|
+
|
|
124
|
+
switch (violation.code) {
|
|
125
|
+
case 'PART_OUTSIDE_OCCUPANCY':
|
|
126
|
+
return {
|
|
127
|
+
message: say(key('message'), { part, axis, mm }, `'${part}' goes ${mm} mm past the declared space on ${axis}.`),
|
|
128
|
+
why: say(key('why'), {}, 'The declared space sets the scale a figure is placed at. What lies past it is left out of that scale, so on a board this part lands outside the place given to the figure.'),
|
|
129
|
+
how: say(key('how'), { part, axis, mm }, `Use "fit to parts" to declare the space from the parts again, or move '${part}' ${mm} mm inward on ${axis}.`)
|
|
130
|
+
}
|
|
131
|
+
case 'OCCUPANCY_UNDECLARED':
|
|
132
|
+
return violation.axis
|
|
133
|
+
? {
|
|
134
|
+
message: say(`figure.gate.occupancy-empty-axis.message`, { axis }, `The declared space is empty on ${axis}.`),
|
|
135
|
+
why: say(key('why'), {}, 'Without a declared space the scale a figure is placed at cannot be set.'),
|
|
136
|
+
how: say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
|
|
137
|
+
}
|
|
138
|
+
: {
|
|
139
|
+
message: say(key('message'), {}, 'This figure has not declared the space it takes up.'),
|
|
140
|
+
why: say(key('why'), {}, 'Without a declared space the scale a figure is placed at cannot be set.'),
|
|
141
|
+
/*
|
|
142
|
+
A new figure is refused this way before it has a part, and 「fit to parts」 has nothing to measure then —
|
|
143
|
+
the screen does not offer it. Telling the author to press it sent them looking for a button that was not
|
|
144
|
+
there (:3300, 2026-09-24).
|
|
145
|
+
*/
|
|
146
|
+
how: hasParts
|
|
147
|
+
? say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
|
|
148
|
+
: say(key('how-no-parts'), {}, 'Add a shape first, then use "fit to parts" to declare the space from it.')
|
|
149
|
+
}
|
|
150
|
+
case 'PARTS_OFF_PLACEMENT_FACE':
|
|
151
|
+
return {
|
|
152
|
+
message: say(key('message'), { part, mm }, `The mounting face of '${part}' is ${mm} mm off the plane the figure stands on.`),
|
|
153
|
+
why: say(key('why'), {}, 'The figure is declared to stand on that face. If the face does not reach the plane, the figure floats on the board by that much, and whatever is placed on it floats too.'),
|
|
154
|
+
how: say(key('how'), { part, mm }, `Move '${part}' ${mm} mm on Y so the face meets the plane, or choose a different mounting face.`)
|
|
155
|
+
}
|
|
156
|
+
case 'PLACEMENT_FACE_TILTED':
|
|
157
|
+
return {
|
|
158
|
+
message: say(key('message'), { part, degrees }, `The mounting face of '${part}' is tilted ${degrees}° from the plane the figure stands on.`),
|
|
159
|
+
why: say(key('why'), {}, 'A tilted face cannot lie on the plane, so how the figure stands is not settled.'),
|
|
160
|
+
how: say(key('how'), { part }, `Choose a mounting face parallel to the plane, or turn '${part}' upright.`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Say = (key: string, params: Record<string, string | number>, english: string) => string
|
|
166
|
+
|
|
167
|
+
function sayerOf(t?: Translate): Say {
|
|
168
|
+
return (key, params, english) => (t ? t(key, { ...params, defaultValue: english }) : english)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function inspectAnySource(
|
|
172
|
+
sourceJson?: string | null,
|
|
173
|
+
releasedJson?: string | null,
|
|
174
|
+
t?: Translate,
|
|
175
|
+
/** The row's `type` — for V3 the only place the name lives, since the asset carries none. */
|
|
176
|
+
rowType?: string
|
|
177
|
+
): Inspection {
|
|
178
|
+
if (!sourceJson) return inspectSource(sourceJson, releasedJson)
|
|
179
|
+
|
|
180
|
+
let format: FigureFormat
|
|
181
|
+
try {
|
|
182
|
+
format = formatOf(sourceJson)
|
|
183
|
+
} catch (e) {
|
|
184
|
+
const findings = (e as FigureSourceError).findings ?? [{ code: 'source-unreadable', message: String(e) }]
|
|
185
|
+
return { blocked: true, findings: findings.map(f => ({ ...f, blocking: true })) }
|
|
186
|
+
}
|
|
187
|
+
if (format === 'v2') return inspectSource(sourceJson, releasedJson)
|
|
188
|
+
|
|
189
|
+
let inspection
|
|
190
|
+
let hasParts = true
|
|
191
|
+
try {
|
|
192
|
+
const asset = parseV3Asset(sourceJson)
|
|
193
|
+
inspection = inspectV3Asset(asset)
|
|
194
|
+
const cost = v3CostOf(asset)
|
|
195
|
+
hasParts = cost.groups + cost.hidden > 0
|
|
196
|
+
} catch (e) {
|
|
197
|
+
const error = e as V3ContractError & { path?: string }
|
|
198
|
+
return {
|
|
199
|
+
blocked: true,
|
|
200
|
+
findings: [{ code: error.code ?? 'v3-asset-invalid', message: error.message ?? String(e), at: error.path, blocking: true }]
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const say = sayerOf(t)
|
|
205
|
+
const findings: GateFinding[] = inspection.violations.map(violation => ({
|
|
206
|
+
code: violation.code.toLowerCase().replace(/_/g, '-'),
|
|
207
|
+
...v3Wording(violation, say, hasParts),
|
|
208
|
+
at: violation.part,
|
|
209
|
+
blocking: true
|
|
210
|
+
}))
|
|
211
|
+
/*
|
|
212
|
+
V2 refuses a machine-made name at release (`type-is-placeholder`, from the source's own `type`). A V3 asset has
|
|
213
|
+
no `type`, so that check never ran and a new V3 figure was published as `FIGURE_<uuid>` — seen on :3300,
|
|
214
|
+
2026-09-24. Publishing freezes the name (ADR-0063 decision 4) and boards call a figure by it, so the same
|
|
215
|
+
refusal stands here, read off the row.
|
|
216
|
+
*/
|
|
217
|
+
if (rowType && isPlaceholderType(rowType)) {
|
|
218
|
+
findings.push({
|
|
219
|
+
code: 'type-is-placeholder',
|
|
220
|
+
message: say('figure.gate.type-is-placeholder.message', { type: rowType }, `'${rowType}' is a temporary type given when the figure was first saved.`),
|
|
221
|
+
why: say('figure.gate.type-is-placeholder.why', {}, 'Boards call a figure by its type, and it cannot change once the figure is published.'),
|
|
222
|
+
how: say('figure.gate.type-is-placeholder.how', {}, 'Set the type in the type field, then publish.'),
|
|
223
|
+
blocking: true
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
if (inspection.coverage.method === 'sampled') {
|
|
227
|
+
const states = inspection.coverage.states
|
|
228
|
+
findings.push({
|
|
229
|
+
code: 'state-range-sampled',
|
|
230
|
+
message: say('figure.gate.state-range-sampled.message', { states }, `The whole range of motion could not be proven, so ${states} poses were checked.`),
|
|
231
|
+
why: say('figure.gate.state-range-sampled.why', {}, 'Between the poses that were checked, a part may still go past the declared space.'),
|
|
232
|
+
how: say('figure.gate.state-range-sampled.how', {}, 'Re-author the motion that could not be proven, and the whole range can be proven.'),
|
|
233
|
+
/* Which operations stopped the proof — a technical list, so it goes where the panel shows a location. */
|
|
234
|
+
at: inspection.coverage.unprovable?.join(' · ') || undefined,
|
|
235
|
+
blocking: false
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
/*
|
|
239
|
+
V2 compares a release with the one before it and names the changes that break placed instances (ADR-0063
|
|
240
|
+
decision 3). Nothing does that for a V3 asset yet, and a V2→V3 conversion has no V3 release to compare with.
|
|
241
|
+
The gate says so with the finding V2 uses when it cannot compare, rather than passing in silence — a
|
|
242
|
+
release that was not checked against the last one is a different fact from one that was and was found safe.
|
|
243
|
+
*/
|
|
244
|
+
if (releasedJson) {
|
|
245
|
+
findings.push({
|
|
246
|
+
code: 'released-unreadable',
|
|
247
|
+
message: say('figure.gate.v3-released-not-compared.message', {}, 'Not compared with the last release — V3 figures have no comparison between releases yet.'),
|
|
248
|
+
why: say('figure.gate.v3-released-not-compared.why', {}, 'Whether this release breaks the instances already placed has not been checked.'),
|
|
249
|
+
blocking: false
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
return { blocked: findings.some(finding => finding.blocking), findings }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What a consumer is handed for a released figure.
|
|
257
|
+
*
|
|
258
|
+
* V2 hands over a compiled blueprint; V3 hands over the asset itself, because the thing that draws a V3 asset
|
|
259
|
+
* — `registerFigureV3` in things-scene — takes the asset and compiles it with the kernel. The format travels
|
|
260
|
+
* with it so the consumer calls the right registration rather than sniffing the payload a second time.
|
|
261
|
+
*/
|
|
262
|
+
export function consumablePayloadOf(sourceJson: string): { format: FigureFormat; blueprint?: unknown; asset?: unknown } {
|
|
263
|
+
if (formatOf(sourceJson) === 'v2') return { format: 'v2', blueprint: compile(JSON.parse(sourceJson)) }
|
|
264
|
+
const asset = parseV3Asset(sourceJson)
|
|
265
|
+
compileV3Asset(asset)
|
|
266
|
+
return { format: 'v3', asset }
|
|
267
|
+
}
|