@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
|
@@ -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 {
|
|
11
|
+
import { inspectAnySource } from './figure-format.js'
|
|
11
12
|
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
12
|
-
import {
|
|
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:
|
|
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 } =
|
|
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 =
|
|
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 =
|
|
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 } =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
32
|
-
groups:
|
|
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
|
+
}
|
package/translations/en.json
CHANGED
|
@@ -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
|
}
|
package/translations/ko.json
CHANGED
|
@@ -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
|
}
|