@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.
Files changed (34) hide show
  1. package/coverage/clover.xml +6 -0
  2. package/coverage/coverage-final.json +1 -0
  3. package/coverage/lcov-report/base.css +224 -0
  4. package/coverage/lcov-report/block-navigation.js +87 -0
  5. package/coverage/lcov-report/favicon.png +0 -0
  6. package/coverage/lcov-report/index.html +101 -0
  7. package/coverage/lcov-report/prettify.css +1 -0
  8. package/coverage/lcov-report/prettify.js +2 -0
  9. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  10. package/coverage/lcov-report/sorter.js +210 -0
  11. package/coverage/lcov.info +0 -0
  12. package/dist-server/service/figure/figure-propose.js +32 -12
  13. package/dist-server/service/figure/figure-propose.js.map +1 -1
  14. package/dist-server/service/figure/figure-query.js +29 -27
  15. package/dist-server/service/figure/figure-query.js.map +1 -1
  16. package/dist-server/service/figure/figure-source.js +6 -7
  17. package/dist-server/service/figure/figure-source.js.map +1 -1
  18. package/dist-server/service/figure/figure-tools.js +4 -3
  19. package/dist-server/service/figure/figure-tools.js.map +1 -1
  20. package/dist-server/tsconfig.tsbuildinfo +1 -0
  21. package/logs/application-2026-09-14-23.log +0 -0
  22. package/package.json +5 -5
  23. package/server/service/figure/figure-ai-flow.test.ts +2 -2
  24. package/server/service/figure/figure-blueprint-query.test.ts +43 -4
  25. package/server/service/figure/figure-gate.test.ts +8 -6
  26. package/server/service/figure/figure-llm-smoke.test.ts +3 -3
  27. package/server/service/figure/figure-propose.test.ts +43 -17
  28. package/server/service/figure/figure-propose.ts +35 -13
  29. package/server/service/figure/figure-quality.test.ts +8 -8
  30. package/server/service/figure/figure-query.ts +34 -29
  31. package/server/service/figure/figure-source.test.ts +13 -5
  32. package/server/service/figure/figure-source.ts +7 -8
  33. package/server/service/figure/figure-tools.test.ts +5 -3
  34. 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.25",
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.25",
29
- "@things-factory/auth-base": "^10.1.24",
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.20",
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": "8407b1e9d1f47f6676af144d4a33c8ac01cb49c4"
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: 0, z: 0 }, size: { x: 300, y: 400, z: 300 } },
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: 1,
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: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
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: 1,
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: 0, z: 0 }, size: { x: 200, y: 200, z: 200 } },
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: -50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
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: 10, z: 0 }, size: { x: 60, y: 20, z: 60 } },
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 -100. */
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: -50, z: 0 }, size: { x: 100, y: 100, z: 100 } },
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: -120, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
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: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } } }] }
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: 0, z: 0 }, size: { x: 280, y: 320, z: 280 } }, material: { token: 'palette.primary' } },
79
- { name: 'port', primitive: 'cube', transform: { position: { x: 0, y: 30, z: -155 }, size: { x: 80, y: 60, z: 30 } }, material: { token: 'palette.accent' } }
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: 0, z: 0 }, size: { x: 300, y: 400, z: 300 } },
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: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
344
- { name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
345
- { name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
346
- { name: 'part-4', primitive: 'cube', transform: { position: { x: 150, y: 0, z: 0 }, size: { x: 50, y: 100, z: 100 } }, material: { token: 'palette.primary' } }
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: 0, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
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: 0, z: 0 }, size: { x: 20, y: 20, z: 20 } },
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
- it('파라미터의 at 이 초가 아니라고 말한다', () => {
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
- 값 0~1 이 `[0, 마지막 키]` 로 맵되므로 키의 절대값은 자세를 안 바꾼다 — 간격만 바꾼다.
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(/only the SPACING between keys matters/)
659
- expect(text).toMatch(/doubles as the seconds a full min-to-max move takes/)
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
- ' { type, base: {x,y,z}, placement?, detailLevel, parts: [...],',
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.<axis>| + size.<axis>/2 <= base.<axis>/2.',
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 = -base.y/2; ceiling: the highest reaches y = +base.y/2; center: no requirement.',
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
- ' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',
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: the first key',
257
- ' is range.min, the last is range.max, and only the SPACING between keys matters.',
258
- ' The last `at` doubles as the seconds a full min-to-max move takes — that is the machine\'s',
259
- ' speed. A hoist that takes 2.5s to run its whole travel ends its curve at 2.5.',
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, capabilities,',
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: 0, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.a' } },
17
- { name: 'box-2', primitive: 'cube', transform: { position: { x: -60, y: 0, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.b' } },
18
- { name: 'shape-3', primitive: 'cube', transform: { position: { x: 60, y: 0, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.c' } },
19
- { name: 'item-4', primitive: 'cube', transform: { position: { x: 180, y: 0, z: 0 }, size: { x: 120, y: 100, z: 180 } }, material: { token: 'palette.d' } }
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: 0, 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: 220, z: 0 }, size: { x: 220, y: 30, z: 220 } }, material: { token: 'palette.primary' } }
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
- let figure = await getRepository(Figure).findOne({
174
- where: { domain: { id: domain.id }, type }
175
- })
176
- if (!figure && domain.parentId) {
177
- figure = await getRepository(Figure).findOne({
178
- where: { domain: { id: domain.parentId }, type }
179
- })
180
- }
181
-
182
- if (!figure) {
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
- let sourceToCompile: string | null = null
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: -350, z: 0 }, size: { x: 100, y: 100, z: 100 } },
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: 1,
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
- it('version이 누락된 구형/외부 정본은 FIGURE_SOURCE_VERSION으로 보정 직렬화한다', () => {
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
- const parsed = JSON.parse(parseSource(withoutVersion).source)
111
- expect(parsed.version).toBe(1)
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 { FIGURE_SOURCE_VERSION, compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
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
- // version이 누락된 구형/외부 입력의 경우 FIGURE_SOURCE_VERSION을 부여하여 스키마를 정규화한다.
69
- // 이미 version이 있는 정본은 포맷 훼손을 막기 위해 sourceJson 원본을 그대로 보존한다.
70
- let normalizedSource = sourceJson
71
- if (parsed && typeof parsed === 'object' && (parsed as any).version === undefined) {
72
- (parsed as any).version = FIGURE_SOURCE_VERSION
73
- normalizedSource = JSON.stringify(parsed, null, 2)
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
  // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.