@things-factory/figure-service 10.1.25 → 10.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/coverage/clover.xml +6 -0
- package/coverage/coverage-final.json +1 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +101 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +0 -0
- package/dist-server/service/figure/figure-propose.js +32 -12
- package/dist-server/service/figure/figure-propose.js.map +1 -1
- package/dist-server/service/figure/figure-query.js +29 -27
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-source.js +6 -7
- package/dist-server/service/figure/figure-source.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +4 -3
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/logs/application-2026-09-14-23.log +0 -0
- package/package.json +5 -5
- package/server/service/figure/figure-ai-flow.test.ts +2 -2
- package/server/service/figure/figure-blueprint-query.test.ts +43 -4
- package/server/service/figure/figure-gate.test.ts +8 -6
- package/server/service/figure/figure-llm-smoke.test.ts +3 -3
- package/server/service/figure/figure-propose.test.ts +43 -17
- package/server/service/figure/figure-propose.ts +35 -13
- package/server/service/figure/figure-quality.test.ts +8 -8
- package/server/service/figure/figure-query.ts +34 -29
- package/server/service/figure/figure-source.test.ts +13 -5
- package/server/service/figure/figure-source.ts +7 -8
- package/server/service/figure/figure-tools.test.ts +5 -3
- package/server/service/figure/figure-tools.ts +4 -3
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.27",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@hatiolab/figure-model": "^0.1.21",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.26",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.27",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.27",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "d04dc762cacf2aac8ba0b0f5d768eca365fc09da"
|
|
37
37
|
}
|
|
@@ -13,10 +13,10 @@ import { proposeFigure } from './figure-propose'
|
|
|
13
13
|
|
|
14
14
|
const DOMAIN = { id: 'domain-mine' }
|
|
15
15
|
const SOURCE = {
|
|
16
|
-
type: 'MIXER', base: { x: 300, y: 500, z: 300 }, detailLevel: 'M',
|
|
16
|
+
version: 2, type: 'MIXER', base: { x: 300, y: 500, z: 300 }, detailLevel: 'M',
|
|
17
17
|
parts: [{
|
|
18
18
|
name: 'body', primitive: 'cylinder', segments: 12,
|
|
19
|
-
transform: { position: { x: 0, y:
|
|
19
|
+
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 300, y: 400, z: 300 } },
|
|
20
20
|
material: { token: 'palette.primary' }
|
|
21
21
|
}]
|
|
22
22
|
}
|
|
@@ -27,14 +27,14 @@ function loadCompiled(modulePath: string): any {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const sampleSource = JSON.stringify({
|
|
30
|
-
version:
|
|
30
|
+
version: 2,
|
|
31
31
|
type: 'TEST_BOX',
|
|
32
32
|
base: { x: 100, y: 100, z: 100 },
|
|
33
33
|
parts: [
|
|
34
34
|
{
|
|
35
35
|
name: 'box-1',
|
|
36
36
|
primitive: 'cube',
|
|
37
|
-
transform: { position: { x: 0, y:
|
|
37
|
+
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
38
38
|
material: { token: 'palette.primary' }
|
|
39
39
|
}
|
|
40
40
|
]
|
|
@@ -134,14 +134,14 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
134
134
|
|
|
135
135
|
const v1Source = sampleSource
|
|
136
136
|
const v2DraftSource = JSON.stringify({
|
|
137
|
-
version:
|
|
137
|
+
version: 2,
|
|
138
138
|
type: 'UPDATING_FIGURE',
|
|
139
139
|
base: { x: 200, y: 200, z: 200 },
|
|
140
140
|
parts: [
|
|
141
141
|
{
|
|
142
142
|
name: 'box-modified',
|
|
143
143
|
primitive: 'cube',
|
|
144
|
-
transform: { position: { x: 0, y:
|
|
144
|
+
transform: { position: { x: 0, y: 100, z: 0 }, size: { x: 200, y: 200, z: 200 } },
|
|
145
145
|
material: { token: 'palette.primary' }
|
|
146
146
|
}
|
|
147
147
|
]
|
|
@@ -176,6 +176,45 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)를 위
|
|
|
176
176
|
expect(result.groups[0].members[0].transform.size.x).toBe(100)
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('a child domain shadows its parent only with a released version', () => {
|
|
180
|
+
const boxOf = (type: string, part: string) =>
|
|
181
|
+
JSON.stringify({ ...JSON.parse(sampleSource), type, parts: [{ ...JSON.parse(sampleSource).parts[0], name: part }] })
|
|
182
|
+
|
|
183
|
+
async function childOf(parent: any) {
|
|
184
|
+
const repo = dataSource!.getRepository(shell.Domain)
|
|
185
|
+
const saved = await repo.save({ name: 'child-domain', subdomain: 'child-domain', parent })
|
|
186
|
+
return repo.findOne({ where: { id: saved.id } })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const contextIn = (domain: any) => ({ state: { domain, user: { id: 'user-1' } } }) as any
|
|
190
|
+
|
|
191
|
+
it('falls back to the parent release when the child has only a draft', async () => {
|
|
192
|
+
const child = await childOf(testDomain)
|
|
193
|
+
expect(child.parentId).toBe(testDomain.id)
|
|
194
|
+
|
|
195
|
+
const repo = dataSource!.getRepository(Figure)
|
|
196
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
197
|
+
await repo.save({ domain: child, type: 'CONVEYOR', name: '테넌트 초안', state: 'draft', source: boxOf('CONVEYOR', 'tenant'), version: 0 })
|
|
198
|
+
|
|
199
|
+
const result = await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(child))
|
|
200
|
+
|
|
201
|
+
expect(result).not.toBeNull()
|
|
202
|
+
expect(result.groups[0].members[0].name).toBe('standard')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('uses the child release when the child has one', async () => {
|
|
206
|
+
const child = await childOf(testDomain)
|
|
207
|
+
|
|
208
|
+
const repo = dataSource!.getRepository(Figure)
|
|
209
|
+
await repo.save({ domain: testDomain, type: 'CONVEYOR', name: '표준', state: 'released', source: boxOf('CONVEYOR', 'standard'), version: 1 })
|
|
210
|
+
await repo.save({ domain: child, type: 'CONVEYOR', name: '테넌트', state: 'released', source: boxOf('CONVEYOR', 'tenant'), version: 1 })
|
|
211
|
+
|
|
212
|
+
const result = await new FigureQuery().figureBlueprint('CONVEYOR', contextIn(child))
|
|
213
|
+
|
|
214
|
+
expect(result.groups[0].members[0].name).toBe('tenant')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
179
218
|
/*
|
|
180
219
|
Every test above calls the resolver method directly, so none of them passes through the
|
|
181
220
|
`@privilege` directive — that is attached when the schema is assembled. They stay green
|
|
@@ -3,18 +3,19 @@ import { inspectSource } from './figure-gate'
|
|
|
3
3
|
describe('release-gate repair candidates', () => {
|
|
4
4
|
it('returns data, not parsed prose, for a measured sizing split', () => {
|
|
5
5
|
const source = {
|
|
6
|
+
version: 2,
|
|
6
7
|
type: 'STACK',
|
|
7
8
|
base: { x: 100, y: 200, z: 100 },
|
|
8
9
|
detailLevel: 'M',
|
|
9
10
|
parts: [
|
|
10
11
|
{
|
|
11
12
|
name: 'foot', primitive: 'cube',
|
|
12
|
-
transform: { position: { x: 0, y:
|
|
13
|
+
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
13
14
|
material: { token: 'palette.primary' }, anchor: { y: 'min' }
|
|
14
15
|
},
|
|
15
16
|
{
|
|
16
17
|
name: 'lamp', primitive: 'cube',
|
|
17
|
-
transform: { position: { x: 0, y:
|
|
18
|
+
transform: { position: { x: 0, y: 110, z: 0 }, size: { x: 60, y: 20, z: 60 } },
|
|
18
19
|
material: { token: 'palette.accent' }, anchor: { y: 'max' }
|
|
19
20
|
}
|
|
20
21
|
]
|
|
@@ -43,15 +44,16 @@ describe('release-gate repair candidates', () => {
|
|
|
43
44
|
* drawing keeps standing. It just cannot be published again until it is fixed.
|
|
44
45
|
*/
|
|
45
46
|
describe('release gate — the base box', () => {
|
|
46
|
-
/** A figure that sits correctly in its box: 200 tall, foot on the floor at
|
|
47
|
+
/** A figure that sits correctly in its box: 200 tall, foot on the floor at 0. */
|
|
47
48
|
const sound = {
|
|
49
|
+
version: 2,
|
|
48
50
|
type: 'POST',
|
|
49
51
|
base: { x: 100, y: 200, z: 100 },
|
|
50
52
|
detailLevel: 'M',
|
|
51
53
|
parts: [
|
|
52
54
|
{
|
|
53
55
|
name: 'post', primitive: 'cube',
|
|
54
|
-
transform: { position: { x: 0, y:
|
|
56
|
+
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
55
57
|
material: { token: 'palette.primary' }
|
|
56
58
|
}
|
|
57
59
|
]
|
|
@@ -65,7 +67,7 @@ describe('release gate — the base box', () => {
|
|
|
65
67
|
})
|
|
66
68
|
|
|
67
69
|
it('refuses a part that overflows the box', () => {
|
|
68
|
-
const over = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y: -
|
|
70
|
+
const over = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y: -20, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
|
|
69
71
|
const inspection = inspect(over)
|
|
70
72
|
const finding = inspection.findings.find(f => f.code === 'part-outside-base')
|
|
71
73
|
|
|
@@ -74,7 +76,7 @@ describe('release gate — the base box', () => {
|
|
|
74
76
|
})
|
|
75
77
|
|
|
76
78
|
it('refuses a figure that does not reach the face its placement names', () => {
|
|
77
|
-
const floating = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y:
|
|
79
|
+
const floating = { ...sound, parts: [{ ...sound.parts[0], transform: { position: { x: 0, y: 100, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
|
|
78
80
|
const inspection = inspect(floating)
|
|
79
81
|
const finding = inspection.findings.find(f => f.code === 'parts-off-placement-face')
|
|
80
82
|
|
|
@@ -73,10 +73,10 @@ describeIf('Figure 실제 LLM smoke', () => {
|
|
|
73
73
|
palette: PALETTE,
|
|
74
74
|
refine: true,
|
|
75
75
|
base: {
|
|
76
|
-
type: 'MIXER_SMOKE', base: { x: 300, y: 400, z: 300 }, detailLevel: 'M',
|
|
76
|
+
version: 2, type: 'MIXER_SMOKE', base: { x: 300, y: 400, z: 300 }, detailLevel: 'M',
|
|
77
77
|
parts: [
|
|
78
|
-
{ name: 'body', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y:
|
|
79
|
-
{ name: 'port', primitive: 'cube', transform: { position: { x: 0, y:
|
|
78
|
+
{ name: 'body', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y: 200, z: 0 }, size: { x: 280, y: 320, z: 280 } }, material: { token: 'palette.primary' } },
|
|
79
|
+
{ name: 'port', primitive: 'cube', transform: { position: { x: 0, y: 230, z: -155 }, size: { x: 80, y: 60, z: 30 } }, material: { token: 'palette.accent' } }
|
|
80
80
|
]
|
|
81
81
|
},
|
|
82
82
|
feedback: [{ outcome: 'discarded', selectedChanges: 0, totalChanges: 2, note: 'the port must read clearly in top view' }]
|
|
@@ -58,6 +58,7 @@ const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
|
58
58
|
/** 온전한 후보. 시험할 곳만 덮어쓴다. */
|
|
59
59
|
function candidate(over: Record<string, unknown> = {}) {
|
|
60
60
|
return {
|
|
61
|
+
version: 2,
|
|
61
62
|
type: 'MIXER',
|
|
62
63
|
base: { x: 300, y: 500, z: 300 },
|
|
63
64
|
detailLevel: 'M',
|
|
@@ -66,7 +67,7 @@ function candidate(over: Record<string, unknown> = {}) {
|
|
|
66
67
|
name: 'body',
|
|
67
68
|
primitive: 'cylinder',
|
|
68
69
|
segments: 12,
|
|
69
|
-
transform: { position: { x: 0, y:
|
|
70
|
+
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 300, y: 400, z: 300 } },
|
|
70
71
|
material: { token: 'palette.primary' }
|
|
71
72
|
}
|
|
72
73
|
],
|
|
@@ -166,9 +167,7 @@ describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이
|
|
|
166
167
|
VIOLATION_CODES: '같다',
|
|
167
168
|
RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
|
|
168
169
|
AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
|
|
169
|
-
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
170
|
-
CLIP_DRIVES:
|
|
171
|
-
'없어지는 이름이다 — 값이 만드는 자세가 `parameters` 로 나갔고(ADR-0051 ①) `compile()` 이 옛 원본의 hold 를 옮길 때만 읽는다. 새로 적는 값이 아니다'
|
|
170
|
+
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
|
|
@@ -221,7 +220,6 @@ describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이
|
|
|
221
220
|
FigureSource: {
|
|
222
221
|
type: '요청에서 온다 — 모델이 지어내면 거절한다',
|
|
223
222
|
parts: '모양 첫 줄에 이미 있다',
|
|
224
|
-
version: '저장할 때 서버가 찍는다',
|
|
225
223
|
styleKit: '읽는 쪽이 0건이다 — 그릴 수 없는 것을 시키면 무동작 설정이 된다'
|
|
226
224
|
},
|
|
227
225
|
FigurePart: {
|
|
@@ -257,6 +255,16 @@ describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이
|
|
|
257
255
|
}
|
|
258
256
|
})
|
|
259
257
|
|
|
258
|
+
describe('joints (ADR-0066)', () => {
|
|
259
|
+
it('teaches the joint shape with the type vocabulary, and a joint channel with no path and a number per key', () => {
|
|
260
|
+
const prompt = rulesPrompt(PALETTE)
|
|
261
|
+
expect(prompt).toContain(`type: ${(model as any).JOINT_TYPES.join(' | ')}`)
|
|
262
|
+
expect(prompt).toMatch(/NO path, NO pivot/)
|
|
263
|
+
expect(prompt).toMatch(/limits must include 0/)
|
|
264
|
+
expect(prompt).toMatch(/ONLY place a parent is written/)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
260
268
|
describe('고치라는 요청일 때', () => {
|
|
261
269
|
it('요청이 안 건드린 것을 그대로 실으라고 못 박는다', async () => {
|
|
262
270
|
/*
|
|
@@ -272,7 +280,7 @@ describe('고치라는 요청일 때', () => {
|
|
|
272
280
|
expect(sent).toMatch(/dropping one is silent/)
|
|
273
281
|
|
|
274
282
|
/* 잃기 쉬운 칸은 이름으로 짚어 준다 — 「전부 지켜라」만으로는 모델이 빠뜨린다. */
|
|
275
|
-
for (const field of ['animations', 'capabilities', 'anchor', 'repeat', 'label', 'materialSlot']) {
|
|
283
|
+
for (const field of ['animations', 'parameters', 'joints', 'capabilities', 'parent', 'anchor', 'repeat', 'label', 'materialSlot']) {
|
|
276
284
|
expect(sent).toContain(field)
|
|
277
285
|
}
|
|
278
286
|
})
|
|
@@ -340,14 +348,14 @@ describe('후보를 만들 때', () => {
|
|
|
340
348
|
|
|
341
349
|
it('요청하면 측정된 품질 근거를 다음 후보 개선에 돌려준다', async () => {
|
|
342
350
|
const generic = candidate({ parts: [
|
|
343
|
-
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -100, y:
|
|
344
|
-
{ name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y:
|
|
345
|
-
{ name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y:
|
|
346
|
-
{ name: 'part-4', primitive: 'cube', transform: { position: { x: 150, y:
|
|
351
|
+
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
352
|
+
{ name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
353
|
+
{ name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
354
|
+
{ name: 'part-4', primitive: 'cube', transform: { position: { x: 150, y: 250, z: 0 }, size: { x: 50, y: 100, z: 100 } }, material: { token: 'palette.primary' } }
|
|
347
355
|
] })
|
|
348
356
|
const refined = candidate({ parts: [{
|
|
349
357
|
name: 'vessel', primitive: 'cylinder', segments: 12,
|
|
350
|
-
transform: { position: { x: 0, y:
|
|
358
|
+
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
|
|
351
359
|
}] })
|
|
352
360
|
const client = stubClient([generic, refined])
|
|
353
361
|
setDefaultAIClient(client)
|
|
@@ -544,7 +552,7 @@ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
|
|
|
544
552
|
const many = Array.from({ length: PART_LIMIT.S + 1 }, (_, at) => ({
|
|
545
553
|
name: `part-${at}`,
|
|
546
554
|
primitive: PRIMITIVE_KINDS[0],
|
|
547
|
-
transform: { position: { x: at * 10, y:
|
|
555
|
+
transform: { position: { x: at * 10, y: 250, z: 0 }, size: { x: 20, y: 20, z: 20 } },
|
|
548
556
|
material: { token: PALETTE[0] }
|
|
549
557
|
}))
|
|
550
558
|
setDefaultAIClient(stubClient([candidate({ detailLevel: 'S', parts: many })]))
|
|
@@ -649,14 +657,32 @@ describe('값이 만드는 자세를 parameters 로 가르친다', () => {
|
|
|
649
657
|
expect(text).toMatch(/not 0 to 1/)
|
|
650
658
|
})
|
|
651
659
|
|
|
652
|
-
|
|
660
|
+
/*
|
|
661
|
+
Source edition 2 measures y up from the bottom face of the base box (ADR-0065). The prompt taught
|
|
662
|
+
the box-centre origin before; a model following that would write sources whose parts sit half a
|
|
663
|
+
box too low and that the release gate refuses.
|
|
664
|
+
*/
|
|
665
|
+
it('teaches y from the bottom face of the base box and asks for version 2', () => {
|
|
666
|
+
const text = prompt()
|
|
667
|
+
expect(text).toMatch(/y is measured UP from its bottom face/)
|
|
668
|
+
expect(text).toMatch(/floor: the lowest part reaches\s+y = 0; ceiling: the highest reaches y = base\.y/)
|
|
669
|
+
expect(text).toMatch(/\{ version: 2, type, base/)
|
|
670
|
+
expect(text).not.toMatch(/-base\.y ?\/ ?2/)
|
|
671
|
+
expect(text).not.toMatch(/origin \(0,0,0\) is the centre of the base box/)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('tells the model a parameter key sits on the value span', () => {
|
|
653
675
|
/*
|
|
654
|
-
|
|
655
|
-
|
|
676
|
+
Keys sit on the 0..1 value span and the format refuses a key above 1 (ADR-0051); the transition
|
|
677
|
+
time is clip.duration. The prompt once taught that the last at doubles as seconds, which led the
|
|
678
|
+
model to write keys in seconds and produce sources the format refuses.
|
|
656
679
|
*/
|
|
657
680
|
const text = prompt()
|
|
658
|
-
expect(text).toMatch(/
|
|
659
|
-
expect(text).toMatch(/
|
|
681
|
+
expect(text).toMatch(/it is the\s+value span, 0 to 1/)
|
|
682
|
+
expect(text).toMatch(/An `at` above 1 is refused/)
|
|
683
|
+
expect(text).toMatch(/clip: \{ duration\?, channels/)
|
|
684
|
+
expect(text).toMatch(/Leave it\s+out and a new value takes effect at once/)
|
|
685
|
+
expect(text).not.toMatch(/doubles as the seconds/)
|
|
660
686
|
})
|
|
661
687
|
|
|
662
688
|
it('두 목록이 이름을 나눠 쓴다고 말한다 — 겹치면 형식이 막는다', () => {
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
SEGMENT_PRESETS,
|
|
18
18
|
SIZING_RULES,
|
|
19
19
|
FIGURE_PLACEMENTS,
|
|
20
|
+
JOINT_TYPES,
|
|
21
|
+
FIGURE_SOURCE_VERSION,
|
|
20
22
|
compile,
|
|
21
23
|
costOf,
|
|
22
24
|
scoreOf,
|
|
@@ -146,13 +148,13 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
146
148
|
'Answer with a single JSON object and nothing else — no prose, no code fence.',
|
|
147
149
|
'',
|
|
148
150
|
'The JSON is a FigureSource:',
|
|
149
|
-
|
|
150
|
-
' animations?: [...], parameters?: [...], capabilities?: [...] }',
|
|
151
|
+
` { version: ${FIGURE_SOURCE_VERSION}, type, base: {x,y,z}, placement?, detailLevel, parts: [...],`,
|
|
152
|
+
' animations?: [...], parameters?: [...], capabilities?: [...], joints?: [...] }',
|
|
151
153
|
'',
|
|
152
154
|
'A part is:',
|
|
153
155
|
' { name, primitive, transform: { position: {x,y,z}, size: {x,y,z}, rotation?: {x,y,z} },',
|
|
154
156
|
' material: { token, preset?, flatShading?, transparent?, emissive? },',
|
|
155
|
-
' segments?, shape?, materialSlot?, keepRound?, sizing?, anchor?, repeat?, label?, capability? }',
|
|
157
|
+
' segments?, shape?, materialSlot?, keepRound?, sizing?, anchor?, repeat?, label?, capability?, parent? }',
|
|
156
158
|
'',
|
|
157
159
|
'Hard vocabulary — anything outside these is rejected:',
|
|
158
160
|
` placement ${FIGURE_PLACEMENTS.join(' | ')} (floor: default, ground equipment; ceiling: overhead hoist/OHT; center: airborne/drone/sensor)`,
|
|
@@ -175,10 +177,11 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
175
177
|
` detailLevel ${DETAIL_LEVELS.join(' | ')}`,
|
|
176
178
|
'',
|
|
177
179
|
'Hard rules about the base box — publication is refused when either is broken:',
|
|
178
|
-
' Every part must sit inside the base box: |position
|
|
180
|
+
' Every part must sit inside the base box: |position.x| + size.x/2 <= base.x/2 (same for z),',
|
|
181
|
+
' and position.y - size.y/2 >= 0 and position.y + size.y/2 <= base.y.',
|
|
179
182
|
' base is the number an instance is scaled by, so a part outside it grows wrongly once placed.',
|
|
180
183
|
' The figure must touch the face its placement names — floor: the lowest part reaches',
|
|
181
|
-
' y =
|
|
184
|
+
' y = 0; ceiling: the highest reaches y = base.y; center: no requirement.',
|
|
182
185
|
' base may be LARGER than the parts. That slack is a declaration (clearance in front of a',
|
|
183
186
|
' conveyor, the pitch of a rack bay) and is never an error. Only overflow and not reaching are.',
|
|
184
187
|
'',
|
|
@@ -191,7 +194,8 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
191
194
|
'Geometry rules:',
|
|
192
195
|
' Axes are the usual 3D ones: x right, y UP, z towards the viewer — same as glTF and three.js.',
|
|
193
196
|
' transform.position is the CENTRE of the part, not a corner.',
|
|
194
|
-
'
|
|
197
|
+
' x and z are measured from the centre of the base box; y is measured UP from its bottom face.',
|
|
198
|
+
` So the floor is y = 0, and a part standing on it has position.y = size.y / 2. Always write version: ${FIGURE_SOURCE_VERSION}.`,
|
|
195
199
|
' base and transform.size are extents along each axis: size.y is the height.',
|
|
196
200
|
' Every number is MILLIMETRES. A conveyor is about 2000 long and 800 tall, not 2 and 0.8.',
|
|
197
201
|
' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',
|
|
@@ -251,12 +255,13 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
251
255
|
' A turning roller, a spinning fan, a blinking beacon.',
|
|
252
256
|
'',
|
|
253
257
|
' parameters A VALUE MAKES THE POSE. Nothing is played back; the figure simply IS this far.',
|
|
254
|
-
' [{ name, label?, default?, range: { unit, min, max }, clip: { channels: [...] } }]',
|
|
258
|
+
' [{ name, label?, default?, range: { unit, min, max }, clip: { duration?, channels: [...] } }]',
|
|
255
259
|
' The instance supplies a PHYSICAL QUANTITY in `range.unit` — 1200 mm, 40 %, 90 deg — and',
|
|
256
|
-
' the runtime maps it onto the curve. So `at` on the keys is NOT seconds here:
|
|
257
|
-
' is range.min,
|
|
258
|
-
'
|
|
259
|
-
'
|
|
260
|
+
' the runtime maps it onto the curve. So `at` on the keys is NOT seconds here: it is the',
|
|
261
|
+
' value span, 0 to 1. at 0 is range.min, at 1 is range.max. An `at` above 1 is refused.',
|
|
262
|
+
' `clip.duration` is the seconds a full min-to-max move takes — the machine\'s speed. Leave it',
|
|
263
|
+
' out and a new value takes effect at once. A hoist that runs its whole travel in 2.5s has',
|
|
264
|
+
' keys at 0 and 1 and duration 2.5.',
|
|
260
265
|
' unit is a free word the screen shows: mm, deg, %, kg. max must be above min.',
|
|
261
266
|
'',
|
|
262
267
|
' USE parameters WHENEVER THE REQUEST ASKS FOR A VALUE THE INSTANCE IS GIVEN, or asks to',
|
|
@@ -270,6 +275,23 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
270
275
|
' Do NOT answer it with a capability. Capabilities say what a figure does in the FLOW;',
|
|
271
276
|
' how a pose is driven is a different axis entirely.',
|
|
272
277
|
'',
|
|
278
|
+
'Joints — a part that moves RELATIVE TO ANOTHER PART, carrying what is attached to it:',
|
|
279
|
+
' An arm whose shoulder turns and carries the elbow, wrist and gripper; a crane whose bridge',
|
|
280
|
+
' slides and carries the trolley and hook. Without joints every channel moves one part alone,',
|
|
281
|
+
' so two turning links do not compose.',
|
|
282
|
+
' parent on a part names the part it is attached to. It is the ONLY place a parent is written.',
|
|
283
|
+
' A part without parent hangs from the figure. Following parents must end; no loops.',
|
|
284
|
+
` joints: [{ name, child, type: ${JOINT_TYPES.join(' | ')}, origin: {x,y,z}, axis: {x,y,z}, limits?: { min, max } }]`,
|
|
285
|
+
' child is the part that moves; its parent is what it moves relative to. One joint per part.',
|
|
286
|
+
' origin is the point it turns about or slides from, in the same coordinates as part',
|
|
287
|
+
' positions (y from the bottom of the base). axis is the direction: turning follows the',
|
|
288
|
+
' right-hand rule. revolute and continuous are in degrees, prismatic in mm.',
|
|
289
|
+
' Parts are drawn in the pose where every joint is 0, so limits must include 0.',
|
|
290
|
+
' continuous has no limits. Joint names and part names must all be different.',
|
|
291
|
+
' A joint moves only through a parameter (or an animation for endless turning): a channel',
|
|
292
|
+
' { target: <joint name>, keys: [{ at, value: <number> }] } — NO path, NO pivot, and each',
|
|
293
|
+
' value is the joint coordinate, within limits. A 6-axis arm is six joints and six parameters.',
|
|
294
|
+
'',
|
|
273
295
|
' Names are shared between the two lists: an instance addresses a curve by name, so an',
|
|
274
296
|
' animation and a parameter may not be called the same thing.',
|
|
275
297
|
' Doors that open together belong in one curve with two channels, not two curves.',
|
|
@@ -291,8 +313,8 @@ function taskPrompt(input: ProposeInput): string {
|
|
|
291
313
|
'',
|
|
292
314
|
'Revise this existing figure. Keep the part names that still mean the same thing —',
|
|
293
315
|
'renaming a part breaks bindings on instances already placed.',
|
|
294
|
-
'Carry over everything the request did not ask you to change. animations,
|
|
295
|
-
'capability, anchor, repeat, label and materialSlot are easy to drop by writing them out of',
|
|
316
|
+
'Carry over everything the request did not ask you to change. animations, parameters, joints,',
|
|
317
|
+
'capabilities, capability, parent, anchor, repeat, label and materialSlot are easy to drop by writing them out of',
|
|
296
318
|
'the JSON, and dropping one is silent — the figure still validates, it just stops doing what',
|
|
297
319
|
'it did. If the request says nothing about movement, the clips come back unchanged.',
|
|
298
320
|
JSON.stringify(input.base)
|
|
@@ -11,12 +11,12 @@ function review(source: FigureSource) {
|
|
|
11
11
|
describe('AI candidate quality review', () => {
|
|
12
12
|
it('calls out only explainable, measurable concerns', () => {
|
|
13
13
|
const result = review({
|
|
14
|
-
type: 'CONVEYOR', base: { x: 600, y: 180, z: 240 }, detailLevel: 'M',
|
|
14
|
+
version: 2, type: 'CONVEYOR', base: { x: 600, y: 180, z: 240 }, detailLevel: 'M',
|
|
15
15
|
parts: [
|
|
16
|
-
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -180, y:
|
|
17
|
-
{ name: 'box-2', primitive: 'cube', transform: { position: { x: -60, y:
|
|
18
|
-
{ name: 'shape-3', primitive: 'cube', transform: { position: { x: 60, y:
|
|
19
|
-
{ name: 'item-4', primitive: 'cube', transform: { position: { x: 180, y:
|
|
16
|
+
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -180, y: 90, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.a' } },
|
|
17
|
+
{ name: 'box-2', primitive: 'cube', transform: { position: { x: -60, y: 90, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.b' } },
|
|
18
|
+
{ name: 'shape-3', primitive: 'cube', transform: { position: { x: 60, y: 90, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.c' } },
|
|
19
|
+
{ name: 'item-4', primitive: 'cube', transform: { position: { x: 180, y: 90, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.d' } }
|
|
20
20
|
]
|
|
21
21
|
})
|
|
22
22
|
|
|
@@ -29,10 +29,10 @@ describe('AI candidate quality review', () => {
|
|
|
29
29
|
|
|
30
30
|
it('does not pretend that a clear model review is a visual aesthetic score', () => {
|
|
31
31
|
const result = review({
|
|
32
|
-
type: 'MIXER', base: { x: 300, y: 500, z: 300 }, detailLevel: 'M',
|
|
32
|
+
version: 2, type: 'MIXER', base: { x: 300, y: 500, z: 300 }, detailLevel: 'M',
|
|
33
33
|
parts: [
|
|
34
|
-
{ name: 'vessel', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y:
|
|
35
|
-
{ name: 'lid', primitive: 'cylinder', segments: 8, transform: { position: { x: 0, y:
|
|
34
|
+
{ name: 'vessel', primitive: 'cylinder', segments: 12, transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 260, y: 420, z: 260 } }, material: { token: 'palette.primary' } },
|
|
35
|
+
{ name: 'lid', primitive: 'cylinder', segments: 8, transform: { position: { x: 0, y: 470, z: 0 }, size: { x: 220, y: 30, z: 220 } }, material: { token: 'palette.primary' } }
|
|
36
36
|
]
|
|
37
37
|
})
|
|
38
38
|
|
|
@@ -169,38 +169,21 @@ export class FigureQuery {
|
|
|
169
169
|
): Promise<any> {
|
|
170
170
|
const { domain } = context.state
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!
|
|
172
|
+
/*
|
|
173
|
+
A child domain shadows the same type in its parent, which is how a tenant replaces the standard
|
|
174
|
+
CONVEYOR with its own. Only a released version shadows. When the child has just a draft, the
|
|
175
|
+
parent's release is used, so starting a draft never makes placed figures disappear (chief
|
|
176
|
+
architect ruling, 2026-09-17). This used to skip the fallback as soon as a child row existed and
|
|
177
|
+
return null.
|
|
178
|
+
*/
|
|
179
|
+
const found =
|
|
180
|
+
(await releasedSourceOf(domain.id, type)) ?? (domain.parentId ? await releasedSourceOf(domain.parentId, type) : null)
|
|
181
|
+
|
|
182
|
+
if (!found) {
|
|
183
183
|
return null
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (figure.state === 'released' && figure.source) {
|
|
189
|
-
sourceToCompile = figure.source
|
|
190
|
-
} else if (figure.id) {
|
|
191
|
-
// 저작 중(draft)인 경우 최근 발행 이력(FigureHistory)을 확인한다
|
|
192
|
-
const latestReleased = await getRepository(FigureHistory).findOne({
|
|
193
|
-
where: { domain: { id: figure.domain?.id ?? domain.id }, originalId: figure.id, state: 'released' },
|
|
194
|
-
order: { version: 'DESC' }
|
|
195
|
-
})
|
|
196
|
-
if (latestReleased && latestReleased.source) {
|
|
197
|
-
sourceToCompile = latestReleased.source
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!sourceToCompile) {
|
|
202
|
-
return null
|
|
203
|
-
}
|
|
186
|
+
const { figure, source: sourceToCompile } = found
|
|
204
187
|
|
|
205
188
|
try {
|
|
206
189
|
const parsed = typeof sourceToCompile === 'string' ? JSON.parse(sourceToCompile) : sourceToCompile
|
|
@@ -211,3 +194,25 @@ export class FigureQuery {
|
|
|
211
194
|
}
|
|
212
195
|
}
|
|
213
196
|
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Finds the released source of a type in one domain, or null.
|
|
200
|
+
*
|
|
201
|
+
* A released row gives its own source; a draft row gives the source of its latest released history.
|
|
202
|
+
* A figure that was never released gives null, because drafts never reach consumers.
|
|
203
|
+
*/
|
|
204
|
+
async function releasedSourceOf(domainId: string, type: string): Promise<{ figure: Figure; source: string } | null> {
|
|
205
|
+
const figure = await getRepository(Figure).findOne({ where: { domain: { id: domainId }, type } })
|
|
206
|
+
if (!figure) return null
|
|
207
|
+
|
|
208
|
+
if (figure.state === 'released' && figure.source) {
|
|
209
|
+
return { figure, source: figure.source }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const latestReleased = await getRepository(FigureHistory).findOne({
|
|
213
|
+
where: { domain: { id: domainId }, originalId: figure.id, state: 'released' },
|
|
214
|
+
order: { version: 'DESC' }
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return latestReleased?.source ? { figure, source: latestReleased.source } : null
|
|
218
|
+
}
|
|
@@ -21,7 +21,7 @@ function part(over: Record<string, unknown> = {}) {
|
|
|
21
21
|
return {
|
|
22
22
|
name: 'body',
|
|
23
23
|
primitive: 'cube',
|
|
24
|
-
transform: { position: { x: 0, y:
|
|
24
|
+
transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
25
25
|
material: { token: 'palette.primary' },
|
|
26
26
|
...over
|
|
27
27
|
}
|
|
@@ -30,7 +30,7 @@ function part(over: Record<string, unknown> = {}) {
|
|
|
30
30
|
/** 온전한 정본 하나. */
|
|
31
31
|
function source(over: Record<string, unknown> = {}) {
|
|
32
32
|
return {
|
|
33
|
-
version:
|
|
33
|
+
version: 2,
|
|
34
34
|
type: 'MIXER',
|
|
35
35
|
base: { x: 300, y: 800, z: 300 },
|
|
36
36
|
detailLevel: 'M',
|
|
@@ -105,10 +105,18 @@ describe('figure-source — 들어오는 문', () => {
|
|
|
105
105
|
expect(parseSource(raw).source).toBe(raw)
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
/*
|
|
109
|
+
A source without a version was written before edition 2, with y measured from the box centre.
|
|
110
|
+
It is refused rather than stamped with the current edition, which would move every part.
|
|
111
|
+
*/
|
|
112
|
+
it('refuses a source without a version instead of stamping the current edition on it', () => {
|
|
109
113
|
const withoutVersion = json({ type: 'MIXER', base: { x: 300, y: 800, z: 300 }, detailLevel: 'M', parts: [part()] })
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
expect(() => parseSource(withoutVersion)).toThrow(FigureSourceError)
|
|
115
|
+
try {
|
|
116
|
+
parseSource(withoutVersion)
|
|
117
|
+
} catch (e) {
|
|
118
|
+
expect((e as FigureSourceError).findings.map(one => one.code)).toContain('source-version-unsupported')
|
|
119
|
+
}
|
|
112
120
|
})
|
|
113
121
|
})
|
|
114
122
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
|
|
2
2
|
|
|
3
3
|
import { FigureFinding } from './figure-type.js'
|
|
4
4
|
|
|
@@ -65,13 +65,12 @@ export function parseSource(sourceJson: string): ParsedSource {
|
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
68
|
+
/*
|
|
69
|
+
No version is stamped here. validate has already refused a source without one: every source saved
|
|
70
|
+
without a version was written with y measured from the box centre, and stamping the current
|
|
71
|
+
edition on it would move every part (ADR-0065).
|
|
72
|
+
*/
|
|
73
|
+
const normalizedSource = sourceJson
|
|
75
74
|
|
|
76
75
|
// 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
|
|
77
76
|
// 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
|