@things-factory/figure-service 10.1.25 → 10.1.26
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 +12 -9
- 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 +32 -16
- package/server/service/figure/figure-propose.ts +13 -9
- 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.26",
|
|
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.26",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.26",
|
|
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": "151bda14d899442c6846532ddded1e2bfe23a2f5"
|
|
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: {
|
|
@@ -340,14 +338,14 @@ describe('후보를 만들 때', () => {
|
|
|
340
338
|
|
|
341
339
|
it('요청하면 측정된 품질 근거를 다음 후보 개선에 돌려준다', async () => {
|
|
342
340
|
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:
|
|
341
|
+
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
342
|
+
{ name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
343
|
+
{ name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
344
|
+
{ 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
345
|
] })
|
|
348
346
|
const refined = candidate({ parts: [{
|
|
349
347
|
name: 'vessel', primitive: 'cylinder', segments: 12,
|
|
350
|
-
transform: { position: { x: 0, y:
|
|
348
|
+
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
|
|
351
349
|
}] })
|
|
352
350
|
const client = stubClient([generic, refined])
|
|
353
351
|
setDefaultAIClient(client)
|
|
@@ -544,7 +542,7 @@ describe('정책을 넘은 후보는 막지 않고 알린다', () => {
|
|
|
544
542
|
const many = Array.from({ length: PART_LIMIT.S + 1 }, (_, at) => ({
|
|
545
543
|
name: `part-${at}`,
|
|
546
544
|
primitive: PRIMITIVE_KINDS[0],
|
|
547
|
-
transform: { position: { x: at * 10, y:
|
|
545
|
+
transform: { position: { x: at * 10, y: 250, z: 0 }, size: { x: 20, y: 20, z: 20 } },
|
|
548
546
|
material: { token: PALETTE[0] }
|
|
549
547
|
}))
|
|
550
548
|
setDefaultAIClient(stubClient([candidate({ detailLevel: 'S', parts: many })]))
|
|
@@ -649,14 +647,32 @@ describe('값이 만드는 자세를 parameters 로 가르친다', () => {
|
|
|
649
647
|
expect(text).toMatch(/not 0 to 1/)
|
|
650
648
|
})
|
|
651
649
|
|
|
652
|
-
|
|
650
|
+
/*
|
|
651
|
+
Source edition 2 measures y up from the bottom face of the base box (ADR-0065). The prompt taught
|
|
652
|
+
the box-centre origin before; a model following that would write sources whose parts sit half a
|
|
653
|
+
box too low and that the release gate refuses.
|
|
654
|
+
*/
|
|
655
|
+
it('teaches y from the bottom face of the base box and asks for version 2', () => {
|
|
656
|
+
const text = prompt()
|
|
657
|
+
expect(text).toMatch(/y is measured UP from its bottom face/)
|
|
658
|
+
expect(text).toMatch(/floor: the lowest part reaches\s+y = 0; ceiling: the highest reaches y = base\.y/)
|
|
659
|
+
expect(text).toMatch(/\{ version: 2, type, base/)
|
|
660
|
+
expect(text).not.toMatch(/-base\.y ?\/ ?2/)
|
|
661
|
+
expect(text).not.toMatch(/origin \(0,0,0\) is the centre of the base box/)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('tells the model a parameter key sits on the value span', () => {
|
|
653
665
|
/*
|
|
654
|
-
|
|
655
|
-
|
|
666
|
+
Keys sit on the 0..1 value span and the format refuses a key above 1 (ADR-0051); the transition
|
|
667
|
+
time is clip.duration. The prompt once taught that the last at doubles as seconds, which led the
|
|
668
|
+
model to write keys in seconds and produce sources the format refuses.
|
|
656
669
|
*/
|
|
657
670
|
const text = prompt()
|
|
658
|
-
expect(text).toMatch(/
|
|
659
|
-
expect(text).toMatch(/
|
|
671
|
+
expect(text).toMatch(/it is the\s+value span, 0 to 1/)
|
|
672
|
+
expect(text).toMatch(/An `at` above 1 is refused/)
|
|
673
|
+
expect(text).toMatch(/clip: \{ duration\?, channels/)
|
|
674
|
+
expect(text).toMatch(/Leave it\s+out and a new value takes effect at once/)
|
|
675
|
+
expect(text).not.toMatch(/doubles as the seconds/)
|
|
660
676
|
})
|
|
661
677
|
|
|
662
678
|
it('두 목록이 이름을 나눠 쓴다고 말한다 — 겹치면 형식이 막는다', () => {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
SEGMENT_PRESETS,
|
|
18
18
|
SIZING_RULES,
|
|
19
19
|
FIGURE_PLACEMENTS,
|
|
20
|
+
FIGURE_SOURCE_VERSION,
|
|
20
21
|
compile,
|
|
21
22
|
costOf,
|
|
22
23
|
scoreOf,
|
|
@@ -146,7 +147,7 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
146
147
|
'Answer with a single JSON object and nothing else — no prose, no code fence.',
|
|
147
148
|
'',
|
|
148
149
|
'The JSON is a FigureSource:',
|
|
149
|
-
|
|
150
|
+
` { version: ${FIGURE_SOURCE_VERSION}, type, base: {x,y,z}, placement?, detailLevel, parts: [...],`,
|
|
150
151
|
' animations?: [...], parameters?: [...], capabilities?: [...] }',
|
|
151
152
|
'',
|
|
152
153
|
'A part is:',
|
|
@@ -175,10 +176,11 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
175
176
|
` detailLevel ${DETAIL_LEVELS.join(' | ')}`,
|
|
176
177
|
'',
|
|
177
178
|
'Hard rules about the base box — publication is refused when either is broken:',
|
|
178
|
-
' Every part must sit inside the base box: |position
|
|
179
|
+
' Every part must sit inside the base box: |position.x| + size.x/2 <= base.x/2 (same for z),',
|
|
180
|
+
' and position.y - size.y/2 >= 0 and position.y + size.y/2 <= base.y.',
|
|
179
181
|
' base is the number an instance is scaled by, so a part outside it grows wrongly once placed.',
|
|
180
182
|
' The figure must touch the face its placement names — floor: the lowest part reaches',
|
|
181
|
-
' y =
|
|
183
|
+
' y = 0; ceiling: the highest reaches y = base.y; center: no requirement.',
|
|
182
184
|
' base may be LARGER than the parts. That slack is a declaration (clearance in front of a',
|
|
183
185
|
' conveyor, the pitch of a rack bay) and is never an error. Only overflow and not reaching are.',
|
|
184
186
|
'',
|
|
@@ -191,7 +193,8 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
191
193
|
'Geometry rules:',
|
|
192
194
|
' Axes are the usual 3D ones: x right, y UP, z towards the viewer — same as glTF and three.js.',
|
|
193
195
|
' transform.position is the CENTRE of the part, not a corner.',
|
|
194
|
-
'
|
|
196
|
+
' x and z are measured from the centre of the base box; y is measured UP from its bottom face.',
|
|
197
|
+
` 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
198
|
' base and transform.size are extents along each axis: size.y is the height.',
|
|
196
199
|
' Every number is MILLIMETRES. A conveyor is about 2000 long and 800 tall, not 2 and 0.8.',
|
|
197
200
|
' part names are stored identifiers — lowercase, hyphenated, unique, and descriptive',
|
|
@@ -251,12 +254,13 @@ export function rulesPrompt(palette: string[]): string {
|
|
|
251
254
|
' A turning roller, a spinning fan, a blinking beacon.',
|
|
252
255
|
'',
|
|
253
256
|
' 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: [...] } }]',
|
|
257
|
+
' [{ name, label?, default?, range: { unit, min, max }, clip: { duration?, channels: [...] } }]',
|
|
255
258
|
' 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
|
-
'
|
|
259
|
+
' the runtime maps it onto the curve. So `at` on the keys is NOT seconds here: it is the',
|
|
260
|
+
' value span, 0 to 1. at 0 is range.min, at 1 is range.max. An `at` above 1 is refused.',
|
|
261
|
+
' `clip.duration` is the seconds a full min-to-max move takes — the machine\'s speed. Leave it',
|
|
262
|
+
' out and a new value takes effect at once. A hoist that runs its whole travel in 2.5s has',
|
|
263
|
+
' keys at 0 and 1 and duration 2.5.',
|
|
260
264
|
' unit is a free word the screen shows: mm, deg, %, kg. max must be above min.',
|
|
261
265
|
'',
|
|
262
266
|
' USE parameters WHENEVER THE REQUEST ASKS FOR A VALUE THE INSTANCE IS GIVEN, or asks to',
|
|
@@ -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
|
// 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
|
|
@@ -34,6 +34,7 @@ const MINE = { id: 'domain-mine' }
|
|
|
34
34
|
const THEIRS = { id: 'domain-theirs' }
|
|
35
35
|
|
|
36
36
|
const SOURCE = {
|
|
37
|
+
version: 2,
|
|
37
38
|
type: 'MIXER',
|
|
38
39
|
base: { x: 300, y: 500, z: 300 },
|
|
39
40
|
detailLevel: 'M',
|
|
@@ -41,7 +42,7 @@ const SOURCE = {
|
|
|
41
42
|
{
|
|
42
43
|
name: 'body',
|
|
43
44
|
primitive: 'cube',
|
|
44
|
-
transform: { position: { x: 0, y:
|
|
45
|
+
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 300, y: 400, z: 300 } },
|
|
45
46
|
material: { token: 'palette.primary' }
|
|
46
47
|
}
|
|
47
48
|
]
|
|
@@ -77,7 +78,8 @@ function install() {
|
|
|
77
78
|
compile: model.compile,
|
|
78
79
|
costOf: model.costOf,
|
|
79
80
|
scoreOf: model.scoreOf,
|
|
80
|
-
validate: model.validate
|
|
81
|
+
validate: model.validate,
|
|
82
|
+
isPlaceholderType: model.isPlaceholderType
|
|
81
83
|
})
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -217,7 +219,7 @@ describe('대화면 문맥에서 대상을 해소한다', () => {
|
|
|
217
219
|
{
|
|
218
220
|
name: 'lid',
|
|
219
221
|
primitive: 'cube',
|
|
220
|
-
transform: { position: { x: 0, y:
|
|
222
|
+
transform: { position: { x: 0, y: 500, z: 0 }, size: { x: 100, y: 20, z: 100 } },
|
|
221
223
|
material: { token: 'palette.primary' }
|
|
222
224
|
}
|
|
223
225
|
]
|
|
@@ -56,7 +56,8 @@ function getDeps(): any {
|
|
|
56
56
|
compile: model.compile,
|
|
57
57
|
costOf: model.costOf,
|
|
58
58
|
scoreOf: model.scoreOf,
|
|
59
|
-
validate: model.validate
|
|
59
|
+
validate: model.validate,
|
|
60
|
+
isPlaceholderType: model.isPlaceholderType
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
return _deps
|
|
@@ -158,7 +159,7 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
|
158
159
|
throw new Error('the unsaved draft is too large to safely review')
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
const { validate } = getDeps()
|
|
162
|
+
const { validate, isPlaceholderType } = getDeps()
|
|
162
163
|
const checked = validate(draft)
|
|
163
164
|
if (checked.errors.length > 0) {
|
|
164
165
|
throw new Error(`the unsaved draft is invalid: ${checked.errors.map((one: any) => one.message).join(' · ')}`)
|
|
@@ -168,7 +169,7 @@ function sourceForTool(row: any, host: any): { source: any; draft: boolean } {
|
|
|
168
169
|
if (draft.type !== row.type) {
|
|
169
170
|
// 템플릿 기본값 'FIGURE', 임시 draftType('FIGURE_...'), 또는 빈 값인 경우
|
|
170
171
|
// 현재 열려 있는 Figure의 실제 type으로 보정/정규화한다.
|
|
171
|
-
const isGenericOrTempType = !draft.type || draft.type === 'FIGURE' || draft.type
|
|
172
|
+
const isGenericOrTempType = !draft.type || draft.type === 'FIGURE' || isPlaceholderType(draft.type)
|
|
172
173
|
if (isGenericOrTempType && row.type) {
|
|
173
174
|
normalizedDraft = { ...draft, type: row.type }
|
|
174
175
|
} else {
|