@things-factory/figure-service 10.1.36 → 10.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.36",
3
+ "version": "10.1.38",
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.35",
26
+ "@hatiolab/figure-model": "^0.1.38",
27
27
  "@operato/typeorm-history": "^10.0.0",
28
28
  "@things-factory/ai-client-base": "^10.1.31",
29
- "@things-factory/auth-base": "^10.1.34",
29
+ "@things-factory/auth-base": "^10.1.38",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.34",
31
+ "@things-factory/shell": "^10.1.38",
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": "3942ac0aa4f004b4700e739889e12ffc35d78794"
36
+ "gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
37
37
  }
@@ -0,0 +1,264 @@
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 } 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 finding = inspectAnySource(outside()).findings.find(f => f.code === 'part-outside-occupancy')!
224
+
225
+ expect(finding.message).toMatch(/^'arm' goes [\d.]+ mm past the declared space on X\.$/)
226
+ expect(finding.message).not.toContain('figure.gate.')
227
+ })
228
+
229
+ /* 코드가 묻는 키가 두 언어에 다 있어야 한다 — 없으면 그 언어의 화면에 영어가 섞인다. */
230
+ it('판정이 묻는 키는 ko · en 에 다 있다', () => {
231
+ const asked: string[] = []
232
+ const t = translatorOf(KO, asked)
233
+ inspectAnySource(outside(), null, t)
234
+ inspectAnySource(bare(), null, t)
235
+ inspectAnySource(v3Source(), v3Source(), t)
236
+
237
+ const keys = [...new Set(asked)]
238
+ expect(keys.length).toBeGreaterThan(5)
239
+ expect(keys.filter(k => !(k in KO))).toEqual([])
240
+ expect(keys.filter(k => !(k in EN))).toEqual([])
241
+ })
242
+ })
243
+
244
+ describe('소비처 — 판마다 받을 것이 다르다', () => {
245
+ it('V2 는 전과 같이 청사진을 받는다', () => {
246
+ const payload = consumablePayloadOf(v2Source())
247
+ expect(payload.format).toBe('v2')
248
+ expect((payload.blueprint as { groups?: unknown[] }).groups!.length).toBeGreaterThan(0)
249
+ expect((payload.blueprint as Record<string, unknown>).format).toBeUndefined()
250
+ })
251
+
252
+ it('V3 는 자산을 그대로 받는다 — 그리는 쪽이 커널로 컴파일한다', () => {
253
+ const payload = consumablePayloadOf(v3Source())
254
+ expect(payload.format).toBe('v3')
255
+ expect((payload.asset as V3Asset).version).toBe('figure-v3-asset-1')
256
+ })
257
+
258
+ it('컴파일되지 않는 V3 자산은 소비처에 넘기지 않고 던진다', () => {
259
+ const broken = JSON.parse(v3Source())
260
+ broken.designInputs = {}
261
+ expect(() => consumablePayloadOf(JSON.stringify(broken))).toThrow()
262
+ })
263
+ })
264
+ })
@@ -0,0 +1,235 @@
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, 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
+ 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): 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
+ how: say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
142
+ }
143
+ case 'PARTS_OFF_PLACEMENT_FACE':
144
+ return {
145
+ message: say(key('message'), { part, mm }, `The mounting face of '${part}' is ${mm} mm off the plane the figure stands on.`),
146
+ 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.'),
147
+ how: say(key('how'), { part, mm }, `Move '${part}' ${mm} mm on Y so the face meets the plane, or choose a different mounting face.`)
148
+ }
149
+ case 'PLACEMENT_FACE_TILTED':
150
+ return {
151
+ message: say(key('message'), { part, degrees }, `The mounting face of '${part}' is tilted ${degrees}° from the plane the figure stands on.`),
152
+ why: say(key('why'), {}, 'A tilted face cannot lie on the plane, so how the figure stands is not settled.'),
153
+ how: say(key('how'), { part }, `Choose a mounting face parallel to the plane, or turn '${part}' upright.`)
154
+ }
155
+ }
156
+ }
157
+
158
+ type Say = (key: string, params: Record<string, string | number>, english: string) => string
159
+
160
+ function sayerOf(t?: Translate): Say {
161
+ return (key, params, english) => (t ? t(key, { ...params, defaultValue: english }) : english)
162
+ }
163
+
164
+ export function inspectAnySource(sourceJson?: string | null, releasedJson?: string | null, t?: Translate): Inspection {
165
+ if (!sourceJson) return inspectSource(sourceJson, releasedJson)
166
+
167
+ let format: FigureFormat
168
+ try {
169
+ format = formatOf(sourceJson)
170
+ } catch (e) {
171
+ const findings = (e as FigureSourceError).findings ?? [{ code: 'source-unreadable', message: String(e) }]
172
+ return { blocked: true, findings: findings.map(f => ({ ...f, blocking: true })) }
173
+ }
174
+ if (format === 'v2') return inspectSource(sourceJson, releasedJson)
175
+
176
+ let inspection
177
+ try {
178
+ inspection = inspectV3Asset(parseV3Asset(sourceJson))
179
+ } catch (e) {
180
+ const error = e as V3ContractError & { path?: string }
181
+ return {
182
+ blocked: true,
183
+ findings: [{ code: error.code ?? 'v3-asset-invalid', message: error.message ?? String(e), at: error.path, blocking: true }]
184
+ }
185
+ }
186
+
187
+ const say = sayerOf(t)
188
+ const findings: GateFinding[] = inspection.violations.map(violation => ({
189
+ code: violation.code.toLowerCase().replace(/_/g, '-'),
190
+ ...v3Wording(violation, say),
191
+ at: violation.part,
192
+ blocking: true
193
+ }))
194
+ if (inspection.coverage.method === 'sampled') {
195
+ const states = inspection.coverage.states
196
+ findings.push({
197
+ code: 'state-range-sampled',
198
+ message: say('figure.gate.state-range-sampled.message', { states }, `The whole range of motion could not be proven, so ${states} poses were checked.`),
199
+ why: say('figure.gate.state-range-sampled.why', {}, 'Between the poses that were checked, a part may still go past the declared space.'),
200
+ how: say('figure.gate.state-range-sampled.how', {}, 'Re-author the motion that could not be proven, and the whole range can be proven.'),
201
+ /* Which operations stopped the proof — a technical list, so it goes where the panel shows a location. */
202
+ at: inspection.coverage.unprovable?.join(' · ') || undefined,
203
+ blocking: false
204
+ })
205
+ }
206
+ /*
207
+ V2 compares a release with the one before it and names the changes that break placed instances (ADR-0063
208
+ decision 3). Nothing does that for a V3 asset yet, and a V2→V3 conversion has no V3 release to compare with.
209
+ The gate says so with the finding V2 uses when it cannot compare, rather than passing in silence — a
210
+ release that was not checked against the last one is a different fact from one that was and was found safe.
211
+ */
212
+ if (releasedJson) {
213
+ findings.push({
214
+ code: 'released-unreadable',
215
+ message: say('figure.gate.v3-released-not-compared.message', {}, 'Not compared with the last release — V3 figures have no comparison between releases yet.'),
216
+ why: say('figure.gate.v3-released-not-compared.why', {}, 'Whether this release breaks the instances already placed has not been checked.'),
217
+ blocking: false
218
+ })
219
+ }
220
+ return { blocked: findings.some(finding => finding.blocking), findings }
221
+ }
222
+
223
+ /**
224
+ * What a consumer is handed for a released figure.
225
+ *
226
+ * V2 hands over a compiled blueprint; V3 hands over the asset itself, because the thing that draws a V3 asset
227
+ * — `registerFigureV3` in things-scene — takes the asset and compiles it with the kernel. The format travels
228
+ * with it so the consumer calls the right registration rather than sniffing the payload a second time.
229
+ */
230
+ export function consumablePayloadOf(sourceJson: string): { format: FigureFormat; blueprint?: unknown; asset?: unknown } {
231
+ if (formatOf(sourceJson) === 'v2') return { format: 'v2', blueprint: compile(JSON.parse(sourceJson)) }
232
+ const asset = parseV3Asset(sourceJson)
233
+ compileV3Asset(asset)
234
+ return { format: 'v3', asset }
235
+ }
@@ -7,11 +7,11 @@ import { Figure } from './figure.js'
7
7
  import { FigureHistory } from './figure-history.js'
8
8
  import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
9
9
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
10
- import { inspectSource } from './figure-gate.js'
10
+ import { inspectAnySource } from './figure-format.js'
11
11
  import { renameDraftType, renameFacts } from './figure-rename.js'
12
- import { parseSource } from './figure-source.js'
12
+ import { parseAnySource } from './figure-format.js'
13
13
  import { ProposeFailure, proposeFigure } from './figure-propose.js'
14
- import { prepareV3Write, compareAndSwapV3 } from './figure-v3-write.js'
14
+ import { prepareV3Write, compareAndSwapV3, pictureOf } from './figure-v3-write.js'
15
15
 
16
16
  @Resolver(Figure)
17
17
  export class FigureMutation {
@@ -23,14 +23,15 @@ export class FigureMutation {
23
23
  @Ctx() context: ResolverContext,
24
24
  @Arg('id', { nullable: true }) id?: string,
25
25
  @Arg('expectedSource', { nullable: true }) expectedSource?: string,
26
- @Arg('creationId', { nullable: true }) creationId?: string
26
+ @Arg('creationId', { nullable: true }) creationId?: string,
27
+ @Arg('thumbnail', { nullable: true }) thumbnail?: string
27
28
  ): Promise<Figure> {
28
29
  const { domain, user, tx } = context.state
29
30
  const repository = tx.getRepository(Figure)
30
31
  if (!id) {
31
32
  if (!creationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(creationId))
32
33
  throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
33
- const prepared = prepareV3Write(source)
34
+ const prepared = { ...prepareV3Write(source), ...pictureOf(thumbnail) }
34
35
  const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } })
35
36
  if (previous) {
36
37
  if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
@@ -44,7 +45,7 @@ export class FigureMutation {
44
45
  // Exact tenant ownership: an inherited catalog entry is not writable in the child domain.
45
46
  const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } })
46
47
  if (!figure) throw Error('편집할 Figure를 찾을 수 없습니다.')
47
- const prepared = prepareV3Write(source, figure, expectedSource)
48
+ const prepared = { ...prepareV3Write(source, figure, expectedSource), ...pictureOf(thumbnail, figure) }
48
49
  // Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
49
50
  // A pessimistic row lock is not supported by the product's SQLite deployment.
50
51
  await compareAndSwapV3(criteria => repository.update(criteria, { ...prepared, updater: user }),
@@ -70,7 +71,7 @@ export class FigureMutation {
70
71
  }
71
72
 
72
73
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
73
- const { source, derived, violations } = parseSource(figure.source)
74
+ const { source, derived, violations } = parseAnySource(figure.source)
74
75
 
75
76
  const saved = await repository.save({
76
77
  ...figure,
@@ -111,7 +112,7 @@ export class FigureMutation {
111
112
  let source = figure.source
112
113
 
113
114
  if (patch.source !== undefined && patch.source !== figure.source) {
114
- const parsed = parseSource(patch.source)
115
+ const parsed = parseAnySource(patch.source)
115
116
  source = parsed.source
116
117
  derived = parsed.derived
117
118
  violations = parsed.violations
@@ -262,7 +263,7 @@ export class FigureMutation {
262
263
  .limit(1)
263
264
  .getOne()
264
265
 
265
- const inspection = inspectSource(figure.source, released?.source)
266
+ const inspection = inspectAnySource(figure.source, released?.source, context.t)
266
267
  if (inspection.blocked) {
267
268
  const reasons = inspection.findings
268
269
  .filter(finding => finding.blocking)
@@ -336,7 +337,7 @@ export class FigureMutation {
336
337
  }
337
338
 
338
339
  /* 형식 검사도 다시 받는다 — 옛 판이 오늘의 형식을 어길 수 있고, 그것을 조용히 들이지 않는다. */
339
- const { source, derived, violations } = parseSource(history.source)
340
+ const { source, derived, violations } = parseAnySource(history.source)
340
341
 
341
342
  const saved = await repository.save({
342
343
  /*
@@ -169,7 +169,14 @@ describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이
169
169
  VIOLATION_CODES: '같다',
170
170
  RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
171
171
  AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
172
- SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
172
+ SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다',
173
+ /*
174
+ V3 커널의 연산자 이름이다. 제안기는 아직 V2 FigureSource 를 쓰므로 저작자가 적는 값이 아니다.
175
+ 제안기가 V3 자산을 쓰게 되는 날 이 세 줄을 지우고 프롬프트가 연산자를 가르쳐야 한다(ADR-0087).
176
+ */
177
+ V3_OPERATORS: 'V3 커널 연산자 이름이다 — 제안기가 V2 정본을 쓰는 동안은 저작자가 적는 값이 아니다',
178
+ V3_NON_AFFINE_OPERATORS: '위 목록의 부분집합이다 — 어느 연산이 다중선형을 안 나르나는 관문이 알 일이다',
179
+ V3_STATE_NON_AFFINE_OPERATORS: '같다'
173
180
  }
174
181
 
175
182
  it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  import { Figure } from './figure.js'
16
16
  import { FigureHistory } from './figure-history.js'
17
- import { inspectSource } from './figure-gate.js'
17
+ import { consumablePayloadOf, inspectAnySource } from './figure-format.js'
18
18
  import { FigureInspection, FigureList } from './figure-type.js'
19
19
 
20
20
  @Resolver(Figure)
@@ -132,7 +132,7 @@ export class FigureQuery {
132
132
  .limit(1)
133
133
  .getOne()
134
134
 
135
- return inspectSource(figure.source, released?.source)
135
+ return inspectAnySource(figure.source, released?.source, context.t)
136
136
  }
137
137
 
138
138
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
@@ -205,8 +205,15 @@ export class FigureQuery {
205
205
  const { figure, source: sourceToCompile, version } = found
206
206
 
207
207
  try {
208
- const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
209
- return compile(parsed)
208
+ /*
209
+ A V2 figure hands over its compiled blueprint, as it always did. A V3 asset hands over the asset
210
+ itself under a `format` tag, because the thing that draws it — `registerFigureV3` — takes the asset
211
+ and compiles it with the kernel. A V2 blueprint carries no `format`, so a consumer that has not been
212
+ taught about V3 keeps working and a V3 payload is recognised rather than guessed at (ADR-0087).
213
+ */
214
+ const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
215
+ const payload = consumablePayloadOf(text)
216
+ return payload.format === 'v3' ? { format: 'v3', type, asset: payload.asset } : payload.blueprint
210
217
  } catch (err) {
211
218
  console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
212
219