@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.
Files changed (31) hide show
  1. package/dist-server/service/figure/figure-format.d.ts +48 -0
  2. package/dist-server/service/figure/figure-format.js +250 -0
  3. package/dist-server/service/figure/figure-format.js.map +1 -0
  4. package/dist-server/service/figure/figure-mutation.d.ts +1 -1
  5. package/dist-server/service/figure/figure-mutation.js +19 -11
  6. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  7. package/dist-server/service/figure/figure-query.d.ts +9 -0
  8. package/dist-server/service/figure/figure-query.js +53 -6
  9. package/dist-server/service/figure/figure-query.js.map +1 -1
  10. package/dist-server/service/figure/figure-rename.js +8 -0
  11. package/dist-server/service/figure/figure-rename.js.map +1 -1
  12. package/dist-server/service/figure/figure-tools.js +11 -7
  13. package/dist-server/service/figure/figure-tools.js.map +1 -1
  14. package/dist-server/service/figure/figure-v3-write.d.ts +17 -1
  15. package/dist-server/service/figure/figure-v3-write.js +21 -4
  16. package/dist-server/service/figure/figure-v3-write.js.map +1 -1
  17. package/dist-server/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +6 -6
  19. package/server/service/figure/figure-format.test.ts +289 -0
  20. package/server/service/figure/figure-format.ts +267 -0
  21. package/server/service/figure/figure-inspect-db.test.ts +130 -0
  22. package/server/service/figure/figure-mutation.ts +19 -11
  23. package/server/service/figure/figure-propose.test.ts +8 -1
  24. package/server/service/figure/figure-query.ts +51 -5
  25. package/server/service/figure/figure-rename.test.ts +27 -0
  26. package/server/service/figure/figure-rename.ts +11 -2
  27. package/server/service/figure/figure-tool-doors.test.ts +13 -13
  28. package/server/service/figure/figure-tools.ts +11 -7
  29. package/server/service/figure/figure-v3-write.ts +21 -5
  30. package/translations/en.json +23 -1
  31. package/translations/ko.json +23 -1
@@ -0,0 +1,130 @@
1
+ /*
2
+ * The inspect doors, read against a real row.
3
+ *
4
+ * `inspectAnySource` is tested on its own in figure-format.test.ts, and that is where the temporary-type refusal was
5
+ * first pinned. It still did not reach the release panel: `inspectFigure` selected only `id` and `source`, so the row's
6
+ * `type` arrived as `undefined` and the refusal never ran. The panel enabled 「발행하기」 and the release mutation —
7
+ * which loads the whole row — refused on the press (2026-09-24). A test of the function could not see that; this
8
+ * one goes through the resolvers and the database.
9
+ */
10
+ import type { DataSource } from 'typeorm'
11
+ import {
12
+ addV3Box,
13
+ applyV3Authoring,
14
+ createV3Asset,
15
+ placeholderType,
16
+ proposeV3Occupancy,
17
+ serializeV3Asset
18
+ } from '@hatiolab/figure-model'
19
+
20
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
21
+ const shell = require('@things-factory/shell')
22
+ const authBase = require('@things-factory/auth-base')
23
+
24
+ let dataSource: DataSource | null = null
25
+ let Figure: any
26
+ let FigureHistory: any
27
+ let FigureQuery: any
28
+ let domain: any
29
+
30
+ function loadCompiled(modulePath: string): any {
31
+ const { existsSync } = require('fs')
32
+ const { join } = require('path')
33
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
34
+ if (!existsSync(resolved)) {
35
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n yarn workspace @things-factory/figure-service build:server`)
36
+ }
37
+ return require(resolved)
38
+ }
39
+
40
+ /** A V3 figure that passes the gate on its own: a box standing on the floor, with its space declared. */
41
+ function sound(): string {
42
+ let asset = createV3Asset('Probe')
43
+ asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
44
+ asset = applyV3Authoring(asset, { kind: 'declare-occupancy', proposal: proposeV3Occupancy(asset, { over: 'range' }), placement: 'floor' })
45
+ return serializeV3Asset(asset)
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ Figure = loadCompiled('service/figure/figure').Figure
50
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
51
+ FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
52
+
53
+ dataSource = new TypeOrmDataSource({
54
+ type: 'sqlite',
55
+ database: ':memory:',
56
+ synchronize: true,
57
+ dropSchema: true,
58
+ namingStrategy: new shell.NamingStrategy(),
59
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
60
+ logging: false
61
+ })
62
+ await dataSource.initialize()
63
+ shell.addDataSource('default', dataSource)
64
+ shell.addDataSource('tx', dataSource)
65
+ })
66
+
67
+ beforeEach(async () => {
68
+ await dataSource!.synchronize(true)
69
+ domain = await dataSource!.getRepository(shell.Domain).save({ name: 'inspect', subdomain: 'inspect' })
70
+ })
71
+
72
+ afterAll(async () => {
73
+ if (!dataSource) return
74
+ shell.removeDataSource('default')
75
+ shell.removeDataSource('tx')
76
+ await dataSource.destroy()
77
+ dataSource = null
78
+ })
79
+
80
+ const context = () => ({ state: { domain, user: { id: 'u' } } }) as any
81
+ const save = (type: string, source = sound()) =>
82
+ dataSource!.getRepository(Figure).save({ domain, type, name: 'probe', state: 'draft', source, version: 0 })
83
+
84
+ describe('inspectFigure — the release panel', () => {
85
+ it('refuses a V3 figure still under its temporary type — the type comes from the row', async () => {
86
+ const figure = await save(placeholderType())
87
+
88
+ const inspection = await new FigureQuery().inspectFigure(figure.id, context())
89
+
90
+ expect(inspection.blocked).toBe(true)
91
+ expect(inspection.findings.map((f: any) => f.code)).toContain('type-is-placeholder')
92
+ })
93
+
94
+ it('lets the same figure through once it is named', async () => {
95
+ const figure = await save('ROLLER_CONVEYOR')
96
+
97
+ expect((await new FigureQuery().inspectFigure(figure.id, context())).blocked).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('inspectFigureDraft — the verdict while editing', () => {
102
+ it('judges the unsaved source, not the stored one, and still reads the row type', async () => {
103
+ const figure = await save(placeholderType())
104
+ let edited = createV3Asset('Probe')
105
+ edited = addV3Box(edited, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
106
+
107
+ const inspection = await new FigureQuery().inspectFigureDraft(serializeV3Asset(edited), context(), figure.id)
108
+ const codes = inspection.findings.map((f: any) => f.code)
109
+
110
+ expect(codes).toContain('occupancy-undeclared')
111
+ expect(codes).toContain('type-is-placeholder')
112
+ })
113
+
114
+ it('a figure never saved has no row type to refuse', async () => {
115
+ const inspection = await new FigureQuery().inspectFigureDraft(sound(), context())
116
+
117
+ expect(inspection.blocked).toBe(false)
118
+ })
119
+
120
+ it('declares the same permission as inspectFigure', () => {
121
+ const { readFileSync } = require('fs')
122
+ const { join } = require('path')
123
+ const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
124
+ const at = source.indexOf('async inspectFigureDraft')
125
+ const preceding = source.slice(0, at)
126
+ const declaration = preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
127
+
128
+ expect(declaration).toContain('category: "figure", privilege: "query"')
129
+ })
130
+ })
@@ -1,17 +1,18 @@
1
1
  import { In } from 'typeorm'
2
2
  import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
3
3
 
4
+ import { placeholderType } from '@hatiolab/figure-model'
4
5
  import { getRepository } from '@things-factory/shell'
5
6
 
6
7
  import { Figure } from './figure.js'
7
8
  import { FigureHistory } from './figure-history.js'
8
9
  import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
9
10
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
10
- import { inspectSource } from './figure-gate.js'
11
+ import { inspectAnySource } from './figure-format.js'
11
12
  import { renameDraftType, renameFacts } from './figure-rename.js'
12
- import { parseSource } from './figure-source.js'
13
+ import { parseAnySource } from './figure-format.js'
13
14
  import { ProposeFailure, proposeFigure } from './figure-propose.js'
14
- import { prepareV3Write, compareAndSwapV3 } from './figure-v3-write.js'
15
+ import { prepareV3Write, compareAndSwapV3, pictureOf } from './figure-v3-write.js'
15
16
 
16
17
  @Resolver(Figure)
17
18
  export class FigureMutation {
@@ -23,28 +24,35 @@ export class FigureMutation {
23
24
  @Ctx() context: ResolverContext,
24
25
  @Arg('id', { nullable: true }) id?: string,
25
26
  @Arg('expectedSource', { nullable: true }) expectedSource?: string,
26
- @Arg('creationId', { nullable: true }) creationId?: string
27
+ @Arg('creationId', { nullable: true }) creationId?: string,
28
+ @Arg('thumbnail', { nullable: true }) thumbnail?: string
27
29
  ): Promise<Figure> {
28
30
  const { domain, user, tx } = context.state
29
31
  const repository = tx.getRepository(Figure)
30
32
  if (!id) {
31
33
  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
34
  throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
33
- const prepared = prepareV3Write(source)
35
+ const prepared = { ...prepareV3Write(source), ...pictureOf(thumbnail) }
34
36
  const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } })
35
37
  if (previous) {
36
38
  if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
37
39
  return previous
38
40
  }
41
+ /*
42
+ The temporary name comes from the format's `placeholderType()`, the one rule the release gate and the AI
43
+ tools use to recognise it. This used to build its own (`FIGURE_<32 hex>`), which `isPlaceholderType` does
44
+ not match, so a new V3 figure could be published under a machine name that then froze (ADR-0063 decision 4)
45
+ — seen on :3300, 2026-09-24. A retry is still safe: the row id is the creation id, not the name.
46
+ */
39
47
  // insert, not save: a concurrent retry must never turn into an unchecked overwrite.
40
- await repository.insert({ ...prepared, id: creationId, type: `FIGURE_${creationId.replace(/-/g, '').toUpperCase()}`,
48
+ await repository.insert({ ...prepared, id: creationId, type: placeholderType(),
41
49
  domain, creator: user, updater: user })
42
50
  return repository.findOneOrFail({ where: { id: creationId, domain: { id: domain.id } } })
43
51
  }
44
52
  // Exact tenant ownership: an inherited catalog entry is not writable in the child domain.
45
53
  const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } })
46
54
  if (!figure) throw Error('편집할 Figure를 찾을 수 없습니다.')
47
- const prepared = prepareV3Write(source, figure, expectedSource)
55
+ const prepared = { ...prepareV3Write(source, figure, expectedSource), ...pictureOf(thumbnail, figure) }
48
56
  // Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
49
57
  // A pessimistic row lock is not supported by the product's SQLite deployment.
50
58
  await compareAndSwapV3(criteria => repository.update(criteria, { ...prepared, updater: user }),
@@ -70,7 +78,7 @@ export class FigureMutation {
70
78
  }
71
79
 
72
80
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
73
- const { source, derived, violations } = parseSource(figure.source)
81
+ const { source, derived, violations } = parseAnySource(figure.source)
74
82
 
75
83
  const saved = await repository.save({
76
84
  ...figure,
@@ -111,7 +119,7 @@ export class FigureMutation {
111
119
  let source = figure.source
112
120
 
113
121
  if (patch.source !== undefined && patch.source !== figure.source) {
114
- const parsed = parseSource(patch.source)
122
+ const parsed = parseAnySource(patch.source)
115
123
  source = parsed.source
116
124
  derived = parsed.derived
117
125
  violations = parsed.violations
@@ -262,7 +270,7 @@ export class FigureMutation {
262
270
  .limit(1)
263
271
  .getOne()
264
272
 
265
- const inspection = inspectSource(figure.source, released?.source)
273
+ const inspection = inspectAnySource(figure.source, released?.source, context.t, figure.type)
266
274
  if (inspection.blocked) {
267
275
  const reasons = inspection.findings
268
276
  .filter(finding => finding.blocking)
@@ -336,7 +344,7 @@ export class FigureMutation {
336
344
  }
337
345
 
338
346
  /* 형식 검사도 다시 받는다 — 옛 판이 오늘의 형식을 어길 수 있고, 그것을 조용히 들이지 않는다. */
339
- const { source, derived, violations } = parseSource(history.source)
347
+ const { source, derived, violations } = parseAnySource(history.source)
340
348
 
341
349
  const saved = await repository.save({
342
350
  /*
@@ -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)
@@ -114,7 +114,12 @@ export class FigureQuery {
114
114
 
115
115
  const figure = await getRepository(Figure).findOne({
116
116
  where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
117
- select: { id: true, source: true }
117
+ /*
118
+ `type` is read too. A V3 asset carries no type, so the temporary-type refusal reads the row's; with only
119
+ id and source selected it saw `undefined`, the panel enabled 「발행하기」, and the release mutation — which
120
+ loads the whole row — refused on the press (2026-09-24).
121
+ */
122
+ select: { id: true, source: true, type: true }
118
123
  })
119
124
 
120
125
  if (!figure) {
@@ -132,7 +137,41 @@ export class FigureQuery {
132
137
  .limit(1)
133
138
  .getOne()
134
139
 
135
- return inspectSource(figure.source, released?.source)
140
+ return inspectAnySource(figure.source, released?.source, context.t, figure.type)
141
+ }
142
+
143
+ /**
144
+ * The same judgement for a source that has not been saved — what the modeller shows **while** the author edits.
145
+ *
146
+ * Without it a V3 author saw nothing until they saved and opened the release panel. The judgement runs here, not
147
+ * in the browser, because the sentences are made from the request's language (`inspectAnySource` with `context.t`)
148
+ * and a second copy on the screen would drift from the one the release refuses with. When `id` names a saved
149
+ * figure, its row type and last release are read as `inspectFigure` does; a figure never saved has neither.
150
+ */
151
+ @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
152
+ @Query(returns => FigureInspection, { description: 'Whether an unsaved figure source would be released, with the reasons.' })
153
+ async inspectFigureDraft(
154
+ @Arg('source') source: string,
155
+ @Ctx() context: ResolverContext,
156
+ @Arg('id', { nullable: true }) id?: string
157
+ ): Promise<FigureInspection> {
158
+ const { domain } = context.state
159
+ if (!id) return inspectAnySource(source, null, context.t)
160
+
161
+ const figure = await getRepository(Figure).findOne({
162
+ where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
163
+ select: { id: true, type: true }
164
+ })
165
+ if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
166
+
167
+ const released = await getRepository(FigureHistory)
168
+ .createQueryBuilder('history')
169
+ .where('history.originalId = :id', { id })
170
+ .orderBy('history.version', 'DESC')
171
+ .limit(1)
172
+ .getOne()
173
+
174
+ return inspectAnySource(source, released?.source, context.t, figure.type)
136
175
  }
137
176
 
138
177
  @Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
@@ -205,8 +244,15 @@ export class FigureQuery {
205
244
  const { figure, source: sourceToCompile, version } = found
206
245
 
207
246
  try {
208
- const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
209
- return compile(parsed)
247
+ /*
248
+ A V2 figure hands over its compiled blueprint, as it always did. A V3 asset hands over the asset
249
+ itself under a `format` tag, because the thing that draws it — `registerFigureV3` — takes the asset
250
+ and compiles it with the kernel. A V2 blueprint carries no `format`, so a consumer that has not been
251
+ taught about V3 keeps working and a V3 payload is recognised rather than guessed at (ADR-0087).
252
+ */
253
+ const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
254
+ const payload = consumablePayloadOf(text)
255
+ return payload.format === 'v3' ? { format: 'v3', type, asset: payload.asset } : payload.blueprint
210
256
  } catch (err) {
211
257
  console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 청사진 컴파일 실패:`, err)
212
258
 
@@ -7,6 +7,8 @@
7
7
  import { readFileSync } from 'fs'
8
8
  import { join } from 'path'
9
9
 
10
+ import { createV3Asset, parseV3Asset, serializeV3Asset } from '@hatiolab/figure-model'
11
+
10
12
  import { FigureRenameRefused, renameDraftType } from './figure-rename'
11
13
 
12
14
  const draft = (over: Record<string, unknown> = {}) => ({
@@ -25,6 +27,19 @@ describe('초안의 type 고치기', () => {
25
27
  expect(JSON.parse(result.source).type).toBe('ROBOT_ARM')
26
28
  })
27
29
 
30
+ /*
31
+ V3 자산에는 type 칸이 없고 V3 형식은 모르는 칸을 거절한다. V2 처럼 원본에 이름을 써 넣었더니
32
+ 그 도형을 다시 열 수 없었다(`parseV3Asset` — "unknown type"). V3 는 행만 바뀐다.
33
+ */
34
+ it('★ V3 도형은 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
35
+ const source = serializeV3Asset(createV3Asset('새 Figure'))
36
+ const result = renameDraftType({ type: 'FIGURE_ABCDEF12', source }, 'ROLLER_CONVEYOR', never)
37
+
38
+ expect(result.type).toBe('ROLLER_CONVEYOR')
39
+ expect(result.source).toBe(source)
40
+ expect(() => parseV3Asset(result.source)).not.toThrow()
41
+ })
42
+
28
43
  it('정본의 다른 값은 건드리지 않는다', () => {
29
44
  const before = JSON.parse(draft().source)
30
45
  const after = JSON.parse(renameDraftType(draft(), 'ROBOT_ARM', never).source)
@@ -91,6 +106,18 @@ describe('문이 이 규칙을 지난다', () => {
91
106
  expect(/renamed !== undefined \? \{ type: renamed \}/.test(mutation)).toBe(true)
92
107
  })
93
108
 
109
+ /*
110
+ V3 저장이 임시 이름을 스스로 지었다(`FIGURE_<32 hex>`). 형식의 `isPlaceholderType` 이 그것을 못 알아봐서
111
+ 발행 판정이 기계 이름을 통과시켰다. 짓는 쪽과 알아보는 쪽이 한 규칙이어야 한다.
112
+ */
113
+ it('새 V3 도형의 임시 이름은 형식의 규칙으로 짓는다 — 판정이 알아보는 그 규칙', () => {
114
+ const mutation = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf8')
115
+ const save = mutation.slice(mutation.indexOf('async saveFigureV3'), mutation.indexOf('async createFigure'))
116
+
117
+ expect(save).toContain('type: placeholderType()')
118
+ expect(save).not.toMatch(/type: `FIGURE_/)
119
+ })
120
+
94
121
  it('문이 type 을 받는 칸을 갖는다', () => {
95
122
  const type = source('figure-type.ts')
96
123
 
@@ -19,6 +19,8 @@
19
19
  * 그래야 규칙을 DB 없이 시험한다. 문(`figure-mutation.ts`)이 그 셋을 읽어 넘긴다.
20
20
  */
21
21
 
22
+ import { V3_ASSET_VERSION } from './figure-format.js'
23
+
22
24
  /** 이름을 바꿀 때 알아야 하는 사실. 부르는 쪽이 저장소에서 읽어 온다. */
23
25
  export interface RenameFacts {
24
26
  /** 발행 판 번호. 0 이면 한 번도 발행되지 않았다. */
@@ -67,9 +69,16 @@ export function renameDraftType(
67
69
  throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
68
70
  }
69
71
 
70
- const parsed = JSON.parse(current.source) as { type?: string }
71
- parsed.type = next
72
+ const parsed = JSON.parse(current.source) as { type?: string; version?: unknown }
72
73
 
74
+ /*
75
+ A V3 asset carries no `type`: the name a board uses is the row's alone, and the V3 schema refuses a field it
76
+ does not know. Writing it in, as V2 needs, left a V3 figure that could no longer be opened — `parseV3Asset`
77
+ answered "unknown type" (checked 2026-09-24). So only the row changes for V3.
78
+ */
79
+ if (parsed.version === V3_ASSET_VERSION) return { type: next, source: current.source }
80
+
81
+ parsed.type = next
73
82
  return { type: next, source: JSON.stringify(parsed) }
74
83
  }
75
84
 
@@ -1,9 +1,9 @@
1
1
  /*
2
- * figure 도구가 지나는 문 — ADR-0072 결정 2, ADR-0071 결정 2 보탬(2026-09-18).
2
+ * The doors figure's tools pass — ADR-0072 decision 2, ADR-0071 decision 2 addendum (2026-09-18).
3
3
  *
4
- * 선언만 해 두고 아무도 읽지 않으면 그 선언은 다음 사람이 지우거나 바꿔도 아무 일이 없다. 이제는
5
- * 읽는 쪽이 생겼다 — 관문이 이 목록으로 도구를 내보낼지 정한다. 그래서 여기서 묻는 것이 둘이다:
6
- * 도구가 문을 적었는가, 그리고 **그 문이 무엇인가를 지키는가.**
4
+ * A declaration nobody reads can be deleted or changed by the next person with no effect. Now it has a
5
+ * reader — the gate decides from this list whether to offer the tool. So this asks two things: did the
6
+ * tool name its doors, and **does each door guard anything.**
7
7
  */
8
8
  import { readdirSync, readFileSync } from 'fs'
9
9
  import { join } from 'path'
@@ -33,15 +33,15 @@ describe('figure tools — declared doors', () => {
33
33
  })
34
34
 
35
35
  /*
36
- * 도구가 가리키는 문이 무엇인가를 지키는가 — 관문이 딛고 선 사실.
36
+ * Does each door the tools point at guard anything — the fact the gate stands on.
37
37
  *
38
- * 2026-09-18 까지 도구 명세가 권한을 베껴 적었고 이 자리가 그 사본을 문과 견줬다. 사본은 없어졌다
39
- * (ADR-0071 결정 2 보탬) — 관문이 AUTH 의 필드 표에서 문의 선언을 직접 읽는다. 그래서 견줄 두 값도
40
- * 없다.
38
+ * Until 2026-09-18 the tool specs copied a privilege and this spot compared the copy with the door.
39
+ * The copy is gone (ADR-0071 decision 2 addendum) — the gate reads the door's declaration from AUTH's
40
+ * field table directly, so there are no longer two values to compare.
41
41
  *
42
- * 대신 관문이 **딛고 선 사실**이 남았다: 이 네 도구가 가리키는 문에 지시자가 붙어 있다는 것. 누가
43
- * `Query.figures` 에서 `@privilege` 를 떼면 그 문은 테넌트 안에서 열리고, 도구도 같이 열린다 —
44
- * 그 자리가 이제 아무 데서도 안 드러난다. 여기서 문다.
42
+ * What remains is **the fact the gate stands on**: the doors these four tools point at carry a
43
+ * directive. Strip `@privilege` from `Query.figures` and that door opens within the tenant, and the
44
+ * tool opens with it — and nothing else would show it. This catches it.
45
45
  */
46
46
  describe('figure tools — the doors they reach are guarded', () => {
47
47
  beforeEach(() => {
@@ -104,13 +104,13 @@ describe('figure tools — the doors they reach are guarded', () => {
104
104
  return found
105
105
  }
106
106
 
107
- it('★ 도구가 가리키는 문마다 그 문을 지키는 지시자가 있다', () => {
107
+ it('★ every door a tool points at carries the directive that guards it', () => {
108
108
  const doors = doorPrivileges()
109
109
 
110
110
  const unguarded = getAllToolSpecs()
111
111
  .flatMap(spec =>
112
112
  ((spec as any).doors ?? []).map((door: string) =>
113
- doors.has(door) ? undefined : `${spec.name} → ${door}: 그런 문이 없거나 그 문이 권한을 선언하지 않는다`
113
+ doors.has(door) ? undefined : `${spec.name} → ${door}: no such door, or the door declares no privilege`
114
114
  )
115
115
  )
116
116
  .filter(Boolean)
@@ -18,11 +18,11 @@
18
18
  * `ai-assistant:mutation` 이 지킨다 — 서로 다른 질문이라 서로 다른 권한이 지킨다. figure 가 board
19
19
  * 의 권한을 빌리지 않는다.
20
20
  *
21
- * ── 권한은 적지 않는다. 문에 적힌 것을 관문이 읽는다 ────────────────────────
22
- * 2026-09-18 까지 도구마다 `privilege: { category: 'figure', ... }` 를 적어 두었다. 문의 지시자와
23
- * 같은 값이라 그때는 맞았고, 문이 선언을 바꾸는 날 어긋난다 — 어긋나면 느슨한 쪽이 이긴다. 이제
24
- * 관문이 `doors` 에 적힌 문의 선언을 AUTH 의 표에서 읽어 묻는다(ADR-0071 결정 2 보탬). 여기 남길
25
- * 것은 **닿는 문**뿐이다.
21
+ * ── No privilege here. The gate reads what the door declares ─────────────────────
22
+ * Until 2026-09-18 each tool carried `privilege: { category: 'figure', ... }`. It matched the door's
23
+ * directive then, and would drift the day the door changed its declaration — and the looser copy wins.
24
+ * Now the gate reads the declaration of each door in `doors` from AUTH's table (ADR-0071 decision 2,
25
+ * addendum). What stays here is **the doors it reaches**.
26
26
  *
27
27
  * ── 규율을 도구와 함께 보낸다 ───────────────────────────────────────────────
28
28
  * 도구 목록만으로는 「언제 반드시 불러야 하는지」를 모른다. twin 이 그 공백에서 사고를 냈다 —
@@ -51,13 +51,17 @@ function getDeps(): any {
51
51
  // eslint-disable-next-line @typescript-eslint/no-var-requires
52
52
  const entity = require('./figure.js')
53
53
  // eslint-disable-next-line @typescript-eslint/no-var-requires
54
- const gate = require('./figure-gate.js')
54
+ const format = require('./figure-format.js')
55
55
  // eslint-disable-next-line @typescript-eslint/no-var-requires
56
56
  const propose = require('./figure-propose.js')
57
57
  _deps = {
58
58
  getRepository: shell.getRepository,
59
59
  Figure: entity.Figure,
60
- inspectSource: gate.inspectSource,
60
+ /*
61
+ The format-aware door, so an AI asked about a V3 model gets the V3 judgement rather than a V2 parser's
62
+ complaint. The tool's own contract is unchanged: it reads, it never saves (ADR-0087).
63
+ */
64
+ inspectSource: format.inspectAnySource,
61
65
  proposeFigure: propose.proposeFigure,
62
66
  compile: model.compile,
63
67
  costOf: model.costOf,
@@ -1,4 +1,4 @@
1
- import { parseV3Asset, serializeV3Asset, compileV3Asset } from '@hatiolab/figure-model'
1
+ import { parseV3Asset, serializeV3Asset, compileV3Asset, v3CostOf } from '@hatiolab/figure-model'
2
2
 
3
3
  export interface V3WriteCriteria {
4
4
  id: string
@@ -22,14 +22,30 @@ export function prepareV3Write(source: string, existing?: { source?: string }, e
22
22
  throw Error('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 적용해 주세요.')
23
23
  }
24
24
  const asset = parseV3Asset(source)
25
- const evaluated = compileV3Asset(asset).evaluate()
25
+ compileV3Asset(asset)
26
+ /*
27
+ The same measure `parseAnySource` uses, so the catalogue's budget columns do not depend on which mutation
28
+ wrote the row. `score` stays null: it measures V2 reuse and nothing has measured V3 (ADR-0087).
29
+ */
30
+ const cost = v3CostOf(asset)
26
31
  return {
27
32
  source: serializeV3Asset(asset),
28
33
  name: asset.name,
29
- // V2 scores are not V3 evidence. Clear them instead of carrying a previous model's score.
30
34
  score: null,
31
- triangles: null,
32
- groups: evaluated.geometry.length,
35
+ triangles: cost.triangles,
36
+ groups: cost.groups,
33
37
  state: 'draft' as const
34
38
  }
35
39
  }
40
+
41
+ /**
42
+ * The picture a card shows, when the modeller sent one.
43
+ *
44
+ * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more
45
+ * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when
46
+ * the picture itself is new, so renaming a figure does not make every card look freshly drawn.
47
+ */
48
+ export function pictureOf(thumbnail?: string, existing?: { thumbnail?: string }) {
49
+ if (thumbnail === undefined || thumbnail === existing?.thumbnail) return {}
50
+ return { thumbnail, thumbnailUpdatedAt: new Date() }
51
+ }
@@ -1,3 +1,25 @@
1
1
  {
2
- "error.figure-release-does-not-compile": "the released source of {type} (version {version}) does not compile. open it in the modeller, fix it and release it again before it draws on a board."
2
+ "error.figure-release-does-not-compile": "the released source of {type} (version {version}) does not compile. open it in the modeller, fix it and release it again before it draws on a board.",
3
+ "figure.gate.occupancy-empty-axis.message": "The declared space is empty on {axis}.",
4
+ "figure.gate.occupancy-undeclared.how": "Use \"fit to parts\" to declare the space from the parts as they are now.",
5
+ "figure.gate.occupancy-undeclared.how-no-parts": "Add a shape first, then use \"fit to parts\" to declare the space from it.",
6
+ "figure.gate.occupancy-undeclared.message": "This figure has not declared the space it takes up.",
7
+ "figure.gate.occupancy-undeclared.why": "Without a declared space the scale a figure is placed at cannot be set.",
8
+ "figure.gate.part-outside-occupancy.how": "Use \"fit to parts\" to declare the space from the parts again, or move '{part}' {mm} mm inward on {axis}.",
9
+ "figure.gate.part-outside-occupancy.message": "'{part}' goes {mm} mm past the declared space on {axis}.",
10
+ "figure.gate.part-outside-occupancy.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.",
11
+ "figure.gate.parts-off-placement-face.how": "Move '{part}' {mm} mm on Y so the face meets the plane, or choose a different mounting face.",
12
+ "figure.gate.parts-off-placement-face.message": "The mounting face of '{part}' is {mm} mm off the plane the figure stands on.",
13
+ "figure.gate.parts-off-placement-face.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.",
14
+ "figure.gate.placement-face-tilted.how": "Choose a mounting face parallel to the plane, or turn '{part}' upright.",
15
+ "figure.gate.placement-face-tilted.message": "The mounting face of '{part}' is tilted {degrees}° from the plane the figure stands on.",
16
+ "figure.gate.placement-face-tilted.why": "A tilted face cannot lie on the plane, so how the figure stands is not settled.",
17
+ "figure.gate.state-range-sampled.how": "Re-author the motion that could not be proven, and the whole range can be proven.",
18
+ "figure.gate.state-range-sampled.message": "The whole range of motion could not be proven, so {states} poses were checked.",
19
+ "figure.gate.state-range-sampled.why": "Between the poses that were checked, a part may still go past the declared space.",
20
+ "figure.gate.type-is-placeholder.how": "Set the type in the type field, then publish.",
21
+ "figure.gate.type-is-placeholder.message": "'{type}' is a temporary type given when the figure was first saved.",
22
+ "figure.gate.type-is-placeholder.why": "Boards call a figure by its type, and it cannot change once the figure is published.",
23
+ "figure.gate.v3-released-not-compared.message": "Not compared with the last release. V3 figures have no comparison between releases yet.",
24
+ "figure.gate.v3-released-not-compared.why": "Whether this release breaks the instances already placed has not been checked."
3
25
  }
@@ -1,3 +1,25 @@
1
1
  {
2
- "error.figure-release-does-not-compile": "{type} 의 발행판({version})이 지금 컴파일되지 않습니다. 저작 화면에서 열어 고친 뒤 다시 발행해야 보드에 나옵니다."
2
+ "error.figure-release-does-not-compile": "{type} 의 발행판({version})이 지금 컴파일되지 않습니다. 저작 화면에서 열어 고친 뒤 다시 발행해야 보드에 나옵니다.",
3
+ "figure.gate.occupancy-empty-axis.message": "공간 선언의 {axis} 가 비어 있습니다.",
4
+ "figure.gate.occupancy-undeclared.how": "「부품에 맞추기」를 누르면 지금 부품으로 공간을 선언합니다.",
5
+ "figure.gate.occupancy-undeclared.how-no-parts": "먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.",
6
+ "figure.gate.occupancy-undeclared.message": "이 도형은 차지하는 공간을 아직 선언하지 않았습니다.",
7
+ "figure.gate.occupancy-undeclared.why": "공간 선언이 없으면 도면에 놓일 때의 배율을 정할 수 없습니다.",
8
+ "figure.gate.part-outside-occupancy.how": "「부품에 맞추기」로 공간을 부품에서 다시 선언하거나, '{part}' 을 {axis} 로 {mm}mm 안으로 옮기세요.",
9
+ "figure.gate.part-outside-occupancy.message": "'{part}' 부품이 공간 선언을 {axis} 로 {mm}mm 넘습니다.",
10
+ "figure.gate.part-outside-occupancy.why": "공간 선언이 도면에 놓일 때의 배율을 정합니다. 넘은 만큼은 그 배율에 들어가지 않아서, 도면에서 이 부품이 도형에 주어진 자리 밖으로 나갑니다.",
11
+ "figure.gate.parts-off-placement-face.how": "'{part}' 을 Y 로 {mm}mm 옮겨 그 면을 평면에 맞추거나, 장착면을 다른 면으로 다시 정하세요.",
12
+ "figure.gate.parts-off-placement-face.message": "장착면으로 정한 '{part}' 부품의 면이 놓이는 평면에서 {mm}mm 떨어져 있습니다.",
13
+ "figure.gate.parts-off-placement-face.why": "도형은 그 면으로 놓인다고 선언돼 있습니다. 닿지 않으면 도면에서 그만큼 떠 보이고, 자리에 놓인 물건도 함께 뜹니다.",
14
+ "figure.gate.placement-face-tilted.how": "평면과 나란한 면을 장착면으로 다시 정하거나, '{part}' 을 바로 세우세요.",
15
+ "figure.gate.placement-face-tilted.message": "장착면으로 정한 '{part}' 부품의 면이 놓이는 평면에서 {degrees}° 기울어 있습니다.",
16
+ "figure.gate.placement-face-tilted.why": "기운 면은 평면에 닿을 수 없어서, 도형이 어떤 자세로 놓이는지 정해지지 않습니다.",
17
+ "figure.gate.state-range-sampled.how": "증명하지 못한 움직임을 다시 저작하면 범위 전체를 증명할 수 있습니다.",
18
+ "figure.gate.state-range-sampled.message": "움직이는 범위 전체를 증명하지 못해 자세 {states}개만 검사했습니다.",
19
+ "figure.gate.state-range-sampled.why": "검사한 자세 사이에서 부품이 공간 선언을 넘을 수 있습니다.",
20
+ "figure.gate.type-is-placeholder.how": "타입 칸에서 타입을 정한 뒤 발행하세요.",
21
+ "figure.gate.type-is-placeholder.message": "'{type}' 은 처음 저장할 때 붙은 임시 타입입니다.",
22
+ "figure.gate.type-is-placeholder.why": "보드는 이 타입으로 도형을 부르고, 발행한 뒤에는 바꿀 수 없습니다.",
23
+ "figure.gate.v3-released-not-compared.message": "직전 발행판과 견주지 못했습니다. V3 도형은 발행 사이의 비교가 아직 없습니다.",
24
+ "figure.gate.v3-released-not-compared.why": "이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았습니다."
3
25
  }