@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.
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 +12 -9
  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 +32 -16
  28. package/server/service/figure/figure-propose.ts +13 -9
  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.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.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.26",
30
30
  "@things-factory/env": "^10.1.20",
31
- "@things-factory/shell": "^10.1.20",
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": "8407b1e9d1f47f6676af144d4a33c8ac01cb49c4"
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: 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: {
@@ -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: 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' } }
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: 0, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
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: 0, z: 0 }, size: { x: 20, y: 20, z: 20 } },
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
- it('파라미터의 at 이 초가 아니라고 말한다', () => {
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
- 값 0~1 이 `[0, 마지막 키]` 로 맵되므로 키의 절대값은 자세를 안 바꾼다 — 간격만 바꾼다.
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(/only the SPACING between keys matters/)
659
- expect(text).toMatch(/doubles as the seconds a full min-to-max move takes/)
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
- ' { type, base: {x,y,z}, placement?, detailLevel, parts: [...],',
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.<axis>| + size.<axis>/2 <= base.<axis>/2.',
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 = -base.y/2; ceiling: the highest reaches y = +base.y/2; center: no requirement.',
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
- ' The origin (0,0,0) is the centre of the base box, so the floor is at y = -base.y / 2.',
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: 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.',
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: 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
  // 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
@@ -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: 0, z: 0 }, size: { x: 300, y: 400, z: 300 } },
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: 250, z: 0 }, size: { x: 100, y: 20, z: 100 } },
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.startsWith('FIGURE_')
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 {