@things-factory/figure-service 10.1.206 → 10.1.208

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 (38) hide show
  1. package/dist-server/service/figure/figure-context.js +0 -2
  2. package/dist-server/service/figure/figure-context.js.map +1 -1
  3. package/dist-server/service/figure/figure-format.d.ts +2 -2
  4. package/dist-server/service/figure/figure-format.js +11 -66
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-propose-steps.d.ts +1 -1
  7. package/dist-server/service/figure/figure-propose-steps.js +3 -5
  8. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  9. package/dist-server/service/figure/figure-type.js +1 -1
  10. package/dist-server/service/figure/figure-type.js.map +1 -1
  11. package/dist-server/service/figure/figure.js.map +1 -1
  12. package/dist-server/tsconfig.tsbuildinfo +1 -1
  13. package/package.json +8 -8
  14. package/scripts/templates.test.mts +2 -3
  15. package/server/service/figure/figure-category-db.test.ts +2 -2
  16. package/server/service/figure/figure-context.test.ts +1 -1
  17. package/server/service/figure/figure-context.ts +0 -2
  18. package/server/service/figure/figure-deprecation-db.test.ts +2 -2
  19. package/server/service/figure/figure-format.test.ts +25 -105
  20. package/server/service/figure/figure-format.ts +14 -69
  21. package/server/service/figure/figure-inspect-db.test.ts +6 -5
  22. package/server/service/figure/figure-llm-smoke.test.ts +1 -1
  23. package/server/service/figure/figure-namespace-db.test.ts +2 -2
  24. package/server/service/figure/figure-propose-steps.test.ts +15 -20
  25. package/server/service/figure/figure-propose-steps.ts +3 -5
  26. package/server/service/figure/figure-public-db.test.ts +2 -2
  27. package/server/service/figure/figure-release-check.test.ts +2 -2
  28. package/server/service/figure/figure-release-record-db.test.ts +7 -6
  29. package/server/service/figure/figure-release-size-db.test.ts +2 -2
  30. package/server/service/figure/figure-tools.test.ts +3 -3
  31. package/server/service/figure/figure-type.ts +1 -1
  32. package/server/service/figure/figure.ts +1 -1
  33. package/translations/en.json +4 -16
  34. package/translations/es.json +4 -16
  35. package/translations/ja.json +4 -16
  36. package/translations/ko.json +10 -22
  37. package/translations/ms.json +4 -16
  38. package/translations/zh.json +4 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.206",
3
+ "version": "10.1.208",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,18 +25,18 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.9.6",
28
+ "@hatiolab/figure-model": "^0.10.0",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
- "@things-factory/ai-client-base": "^10.1.206",
31
- "@things-factory/attachment-base": "^10.1.206",
32
- "@things-factory/auth-base": "^10.1.206",
30
+ "@things-factory/ai-client-base": "^10.1.208",
31
+ "@things-factory/attachment-base": "^10.1.208",
32
+ "@things-factory/auth-base": "^10.1.208",
33
33
  "@things-factory/env": "^10.1.149",
34
- "@things-factory/setting-base": "^10.1.206",
35
- "@things-factory/shell": "^10.1.206",
34
+ "@things-factory/setting-base": "^10.1.208",
35
+ "@things-factory/shell": "^10.1.208",
36
36
  "graphql-scalars": "^1.22.4",
37
37
  "graphql-upload": "^15.0.2",
38
38
  "jszip": "^3.10.1",
39
39
  "koa-router": "^7.4.0"
40
40
  },
41
- "gitHead": "18588a058a4a6cb21c379b2da5517e077c6442ba"
41
+ "gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
42
42
  }
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict'
12
12
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
 
15
- import { applyFigureProposal, applyFigureRecipe, checkFigureRecipe, compileFigureAsset, compileFigureDocument, inspectFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
15
+ import { applyFigureRecipe, checkFigureRecipe, compileFigureAsset, compileFigureDocument, inspectFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
16
16
  import type { FigureAsset } from '@hatiolab/figure-model'
17
17
 
18
18
  import { buildTemplate, modelFileOf, RECIPES } from './build-templates.mts'
@@ -50,8 +50,7 @@ for (const name of names) {
50
50
  const { models } = await buildTemplate(name)
51
51
  for (const { values, asset } of models) {
52
52
  const at = JSON.stringify(values)
53
- const declared = applyFigureProposal(asset, [{ kind: 'fit-room', placement: 'floor' } as never])
54
- assert.deepEqual(inspectFigureAsset(declared).violations, [], `${at}: the release gate`)
53
+ assert.deepEqual(inspectFigureAsset(asset).violations, [], `${at}: the release gate`)
55
54
  for (const k of [0.5, 2]) assert.doesNotThrow(() => compileFigureAsset(withHost(asset, k)).evaluate(), `${at}: host × ${k}`)
56
55
  for (const input of asset.document.model.inputs.filter(i => i.role === 'state'))
57
56
  for (const v of [input.min!, input.max!]) assert.doesNotThrow(() => compileFigureAsset(asset).evaluate({ [input.id]: v }), `${at}: ${input.id} at ${v}`)
@@ -8,7 +8,7 @@ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
8
  const shell = require('@things-factory/shell')
9
9
  const authBase = require('@things-factory/auth-base')
10
10
  const settingBase = require('@things-factory/setting-base')
11
- const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
11
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring } = require('@hatiolab/figure-model')
12
12
 
13
13
  let dataSource: DataSource | null = null
14
14
  let Figure: any
@@ -60,7 +60,7 @@ afterEach(() => categories.clearFigureCategories())
60
60
  const releasable = () => {
61
61
  let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
62
62
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
63
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
63
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
64
64
  }
65
65
  let seq = 0
66
66
  async function context() {
@@ -196,7 +196,7 @@ describe('a proposal changes only what it was shown', () => {
196
196
  { kind: 'add-part', part: { id: 'lamp', kind: 'sphere', dimensions: { radius: 40, radiusY: 40 } } },
197
197
  { kind: 'attach', part: 'lamp', to: { part: 'shelf-9', face: 'top' } },
198
198
  { kind: 'link-dimension', part: 'lamp', axis: 'x', source: { of: 'part', part: 'shelf-7', axis: 'x' }, times: 0.1 },
199
- { kind: 'fit-room', placement: 'floor' }
199
+ { kind: 'set-placement', placement: 'floor' }
200
200
  ]
201
201
  expect(unreadPartEdits(over, steps as never, shown)).toEqual([])
202
202
  })
@@ -323,7 +323,6 @@ type Subject = string | undefined
323
323
  */
324
324
  const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: FigureAsset) => Subject } = {
325
325
  'add-part': () => undefined,
326
- 'fit-room': () => undefined,
327
326
  'set-section-corner': step => step.part,
328
327
  'set-cavity': step => step.part,
329
328
  'set-section': step => step.part,
@@ -350,7 +349,6 @@ const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: Figu
350
349
  'duplicate-part': step => step.target,
351
350
  'move-part': step => step.target,
352
351
  'set-placement': () => undefined,
353
- 'declare-occupancy': () => undefined,
354
352
  'declare-instance-size': () => undefined,
355
353
  'clear-mounting-face': () => undefined,
356
354
  'scale-uniformly': () => undefined,
@@ -8,7 +8,7 @@ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
8
  const shell = require('@things-factory/shell')
9
9
  const authBase = require('@things-factory/auth-base')
10
10
  const settingBase = require('@things-factory/setting-base')
11
- const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
11
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring } = require('@hatiolab/figure-model')
12
12
 
13
13
  let FigureNamespace: any
14
14
  let dataSource: DataSource | null = null
@@ -60,7 +60,7 @@ async function namespaced(domain: any) {
60
60
  const releasable = () => {
61
61
  let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
62
62
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
63
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
63
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
64
64
  }
65
65
 
66
66
  let seq = 0
@@ -6,22 +6,17 @@
6
6
  * - a source that does not name the format is refused -- what cannot be identified is not stored;
7
7
  * - the budget columns (`triangles`, `groups`) and the score are the kernel's.
8
8
  */
9
- import { createFigureAsset, addFigureBox, addFigurePrimitive, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, placeholderType, figureScoreOf } from '@hatiolab/figure-model'
9
+ import { createFigureAsset, addFigureBox, addFigurePrimitive, serializeFigureAsset, applyFigureAuthoring, placeholderType, figureScoreOf, inspectFigureAsset } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
12
  import { FigureSourceError, consumablePayloadOf, inspectSource, parseSource, releaseRecordOf } from './figure-format'
13
13
 
14
- /** 온전한 자산 하나: 바닥에 선 상자와, 부품에서 제안받아 확정한 점유 부피. */
14
+ /** 온전한 자산 하나: 바닥에 선 상자. */
15
15
  function figureAsset(): FigureAsset {
16
16
  let asset = createFigureAsset('Mixer')
17
17
  asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
18
18
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
19
- asset = applyFigureAuthoring(asset, {
20
- kind: 'declare-occupancy',
21
- proposal: proposeFigureOccupancy(asset, { over: 'range' }),
22
- placement: 'floor'
23
- })
24
- return asset
19
+ return applyFigureAuthoring(asset, { kind: 'set-placement', placement: 'floor' })
25
20
  }
26
21
  const figureSource = () => serializeFigureAsset(figureAsset())
27
22
 
@@ -68,29 +63,13 @@ describe('figure-format — 판을 알아보는 문', () => {
68
63
  })
69
64
 
70
65
  describe('발행 판정', () => {
71
- it('제 부피 안에 든 자산은 막지 않는다', () => {
66
+ it('온전한 자산은 막지 않는다', () => {
72
67
  const inspection = inspectSource(figureSource())
73
68
  expect(inspection.blocked).toBe(false)
74
69
  })
75
70
 
76
- it('선언한 부피 밖으로 나간 부품은 이름과 함께 막는다', () => {
77
- let asset = figureAsset()
78
- asset = addFigureBox(asset, { id: 'arm', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
79
- const inspection = inspectSource(serializeFigureAsset(asset))
80
- expect(inspection.blocked).toBe(true)
81
- expect(inspection.findings.some(f => f.code === 'part-outside-occupancy' && f.at === 'arm')).toBe(true)
82
- })
83
-
84
- it('부피를 선언하지 않은 자산은 막는다', () => {
85
- let asset = createFigureAsset('Bare')
86
- asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
87
- const inspection = inspectSource(serializeFigureAsset(asset))
88
- expect(inspection.blocked).toBe(true)
89
- expect(inspection.findings.some(f => f.code === 'occupancy-undeclared')).toBe(true)
90
- })
91
-
92
- it('전 범위를 증명하지 못한 판정은 막지 않고 그 사실을 말한다', () => {
93
- /* 상태에 따라 도는 부품은 모서리만으로 증명되지 않는다 — 표본 검사였음을 발행 화면이 읽는다. */
71
+ it('표본으로 검사한 판정에도 다시 저작하라는 말은 없다', () => {
72
+ /* 상태에 따라 도는 부품은 표본으로 검사된다. 그 사이에서 넘을 선언된 공간이 없으니 화면이 할 말도 없다(ADR-0145). */
94
73
  let asset = figureAsset()
95
74
  asset = addFigurePrimitive(asset, { id: 'wheel', kind: 'cylinder', dimensions: { radius: 20, length: 20 }, position: { x: 0, y: 20, z: 0 } })
96
75
  const m = asset.document.model as any
@@ -108,19 +87,11 @@ describe('figure-format — 판을 알아보는 문', () => {
108
87
  pose.params = { ...pose.params, to: 'wheel.spun' }
109
88
  m.nodes.push({ id: 'wheel.world', op: 'compose@1', args: ['wheel.turn.p', pose.outputs.pose], outputs: { pose: 'wheel.wp' } })
110
89
  m.nodes.find((n: any) => n.id === 'wheel').args = [m.nodes.find((n: any) => n.id === 'wheel.shape').outputs.shape, 'wheel.wp']
111
- asset = applyFigureAuthoring(asset, {
112
- kind: 'declare-occupancy',
113
- proposal: proposeFigureOccupancy(asset, { over: 'range' }),
114
- placement: 'floor'
115
- })
116
90
 
91
+ expect(inspectFigureAsset(asset).coverage.method).toBe('sampled')
117
92
  const inspection = inspectSource(serializeFigureAsset(asset))
118
- const sampled = inspection.findings.find(f => f.code === 'state-range-sampled')
119
- expect(sampled).toBeDefined()
120
- expect(sampled!.blocking).toBe(false)
121
- /* 증명을 막은 움직임은 사람이 붙인 이름으로 — 연산 이름과 영어 설명은 화면에 나가지 않는다. */
122
- expect(sampled!.at).toBe('wheel')
123
- expect(`${sampled!.at}${sampled!.why}`).not.toMatch(/axis-turn@1|state-dependent|[a-z]+@\d/)
93
+ expect(inspection.blocked).toBe(false)
94
+ expect(inspection.findings).toEqual([])
124
95
  })
125
96
 
126
97
  it('정본이 없으면 전과 같이 그 말로 막는다', () => {
@@ -130,7 +101,7 @@ describe('figure-format — 판을 알아보는 문', () => {
130
101
  it('바뀐 것 없는 재발행은 아무 말도 없이 지난다', () => {
131
102
  const again = inspectSource(figureSource(), figureSource())
132
103
  expect(again.blocked).toBe(false)
133
- expect(again.findings.filter(f => f.code !== 'state-range-sampled')).toEqual([])
104
+ expect(again.findings).toEqual([])
134
105
  })
135
106
 
136
107
  it('놓인 인스턴스가 기대던 것을 없앤 재발행은 막는다 — 없어진 능력을 이름으로 댄다', () => {
@@ -163,28 +134,6 @@ describe('figure-format — 판을 알아보는 문', () => {
163
134
  return text.replace(/\{(\w+)\}/g, (_, name) => String(options[name] ?? `{${name}}`))
164
135
  }
165
136
 
166
- const outside = () => {
167
- let asset = figureAsset()
168
- asset = addFigureBox(asset, { id: 'arm', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
169
- return serializeFigureAsset(asset)
170
- }
171
- const bare = () => {
172
- let asset = createFigureAsset('Bare')
173
- asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
174
- return serializeFigureAsset(asset)
175
- }
176
-
177
- it('넘은 부품은 이름 · 축 · 넘은 양을 화면 말투로 말하고, 왜와 어떻게를 함께 낸다', () => {
178
- const finding = inspectSource(outside(), null, translatorOf(KO)).findings.find(f => f.code === 'part-outside-occupancy')!
179
-
180
- expect(finding.message).toMatch(/^도형이 차지하는 공간을 'arm' 부품이 X 로 [\d.]+mm 넘습니다\.$/)
181
- expect(finding.why).toMatch(/니다\.$/)
182
- expect(finding.how).toContain('부품에 맞추기')
183
- expect(finding.how).toMatch(/세요\.$/)
184
- /* 영어 로그 문장이 화면으로 새지 않는다. */
185
- expect(`${finding.message}${finding.why}${finding.how}`).not.toMatch(/declared volume|leaves/)
186
- })
187
-
188
137
  it('확장 속성의 키가 없어지면 발행을 막고 키 이름으로 말한다; 값이 바뀌면 알리기만 한다 (ADR-0123)', () => {
189
138
  const carrier = { type: 'ROLL', dimensions: { width: 1000, length: 600, height: 200 } }
190
139
  const withKey = applyFigureAuthoring(figureAsset(), { kind: 'set-extension', key: 'fmsim/carrier', value: carrier })
@@ -203,26 +152,9 @@ describe('figure-format — 판을 알아보는 문', () => {
203
152
  })
204
153
 
205
154
  /* 첫 15분 다시 재기(10-16 저녁): 판정 문구만 옛 말 「공간 선언」을 썼다. 화면 말은 ADR-0097 §9 Q2 A 의 표다. */
206
- it('판정 문구는 화면 말을 쓴다 — 「차지하는 공간」, 「공간 선언」은 쓰지 않는다', () => {
155
+ it('판정 문구는 화면 말을 쓴다 — 「공간 선언」은 쓰지 않는다', () => {
207
156
  const said = Object.entries(KO).filter(([key]) => key.startsWith('figure.gate.'))
208
157
  expect(said.filter(([, text]) => /공간 선언|공간을[^.]*선언/.test(text)).map(([key]) => key)).toEqual([])
209
- const finding = inspectSource(outside(), null, translatorOf(KO)).findings.find(f => f.code === 'part-outside-occupancy')!
210
- expect(`${finding.message}${finding.why}${finding.how}`).toContain('차지하는 공간')
211
- })
212
-
213
- it('차지하는 공간을 정하지 않은 도형은 무엇을 누르면 되는지 말한다', () => {
214
- const finding = inspectSource(bare(), null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
215
-
216
- expect(finding.message).toBe('이 도형은 차지하는 공간을 아직 정하지 않았습니다.')
217
- expect(finding.how).toContain('부품에 맞추기')
218
- })
219
-
220
- it('부품이 없는 새 도형에는 도형을 먼저 추가하라고 한다 — 화면은 그때 맞추기 단추를 내지 않는다', () => {
221
- const empty = serializeFigureAsset(createFigureAsset('Empty'))
222
- const finding = inspectSource(empty, null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
223
-
224
- expect(finding.how).toBe('먼저 도형을 추가한 뒤, 「부품에 맞추기」로 차지하는 공간을 정하세요.')
225
- expect(inspectSource(empty).findings.find(f => f.code === 'occupancy-undeclared')!.how).toMatch(/^Add a shape first/)
226
158
  })
227
159
 
228
160
  /*
@@ -295,9 +227,9 @@ describe('figure-format — 판을 알아보는 문', () => {
295
227
  })
296
228
 
297
229
  it('번역이 없으면 영어로 답한다 — 키를 찍지 않는다', () => {
298
- const finding = inspectSource(outside()).findings.find(f => f.code === 'part-outside-occupancy')!
230
+ const finding = inspectSource(splitting()).findings.find(f => f.code === 'parts-split-on-resize')!
299
231
 
300
- expect(finding.message).toMatch(/^'arm' goes [\d.]+ mm past the space the figure takes on X\.$/)
232
+ expect(finding.message).toBe("At 2x on Y, 'lamp' come away from the rest of the figure.")
301
233
  expect(finding.message).not.toContain('figure.gate.')
302
234
  })
303
235
 
@@ -321,8 +253,9 @@ describe('figure-format — 판을 알아보는 문', () => {
321
253
  it('판정이 묻는 키는 ko · en 에 다 있다', () => {
322
254
  const asked: string[] = []
323
255
  const t = translatorOf(KO, asked)
324
- inspectSource(outside(), null, t)
325
- inspectSource(bare(), null, t)
256
+ inspectSource(splitting(), null, t)
257
+ const raised = addFigureBox(createFigureAsset('Raised'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 150, z: 0 } })
258
+ inspectSource(serializeFigureAsset(applyFigureAuthoring(raised, { kind: 'set-mounting-face', part: 'body', face: 'bottom' })), null, t)
326
259
  inspectSource(figureSource(), figureSource(), t)
327
260
  inspectSource(figureSource(), null, t, 'FIGURE_ABCDEF12')
328
261
 
@@ -352,22 +285,23 @@ describe('figure-format — 판을 알아보는 문', () => {
352
285
  let asset = createFigureAsset('Mixer')
353
286
  asset = addFigureBox(asset, { id: 'body', size: { x: 300, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
354
287
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
355
- return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
288
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'set-placement', placement: 'floor' }))
356
289
  }
357
290
  it('a first release: its format, and nothing changed', () => {
358
291
  const record = releaseRecordOf(serializeFigureAsset(figureAsset()))
359
292
  expect([record.format, record.changes]).toEqual(['figure-asset-1', []])
360
- /* Not sized from the instance: nothing measured across sizes, only where its origin is. Its plan is its one body from above. */
361
- expect(record.metrics).toEqual({ origin: { centred: true, off: [] } })
293
+ /* Not sized from the instance: nothing measured across sizes, only its body and the room it sweeps. Its plan is its one body from above. */
294
+ const box = { x: { min: -50, max: 50 }, y: { min: 0, max: 100 }, z: { min: -50, max: 50 } }
295
+ expect(record.metrics).toEqual({ body: box, motionRange: box })
362
296
  expect(record.topView.shapes.map(s => s.id)).toEqual(['body'])
363
297
  expect(record.topView.size).toBeNull()
364
298
  })
365
- it('the same again changes nothing; a wider room is said and does not block', () => {
299
+ it('the same again changes nothing; a wider body is said and does not block', () => {
366
300
  const v1 = serializeFigureAsset(figureAsset())
367
301
  expect(releaseRecordOf(v1, v1).changes).toEqual([])
368
- const room = releaseRecordOf(wider(), v1).changes!.find(c => c.code === 'occupancy-changed')!
369
- expect(room.blocking).toBe(false)
370
- expect(String(room.values.after)).toMatch(/x -150…150/)
302
+ const size = releaseRecordOf(wider(), v1).changes!.find(c => c.code === 'size-changed')!
303
+ expect(size.blocking).toBe(false)
304
+ expect(String(size.values.after)).toMatch(/x -150…150/)
371
305
  })
372
306
  it('a last release that cannot be read is "not compared" (null), never "nothing changed" ([])', () => {
373
307
  expect(releaseRecordOf(serializeFigureAsset(figureAsset()), '{ not json').changes).toBeNull()
@@ -375,7 +309,7 @@ describe('figure-format — 판을 알아보는 문', () => {
375
309
  it('every new change has its sentence in both languages', () => {
376
310
  const KO = require('../../../translations/ko.json') as Record<string, string>
377
311
  const EN = require('../../../translations/en.json') as Record<string, string>
378
- for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', 'extension-gone', 'extension-changed', 'extension-added'])
312
+ for (const code of ['size-changed', 'motion-range-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', 'extension-gone', 'extension-changed', 'extension-added'])
379
313
  for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
380
314
  })
381
315
  it('the data changes have their sentences under figure-model 0.6.6\'s codes, in all six languages', () => {
@@ -417,7 +351,6 @@ describe('figure-format — 판을 알아보는 문', () => {
417
351
  })
418
352
  it('a figure that names its mounting face is checked on that face, not told twice', () => {
419
353
  let a = conveyor(100)
420
- a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a) })
421
354
  a = applyFigureAuthoring(a, { kind: 'set-mounting-face', part: 'leg', face: 'bottom' })
422
355
  const found = inspectSource(serializeFigureAsset(a), null, undefined, 'CONVEYOR').findings
423
356
  expect(found.map(f => f.code)).not.toContain('off-mounting-plane')
@@ -431,17 +364,4 @@ describe('figure-format — 판을 알아보는 문', () => {
431
364
  }
432
365
  })
433
366
  })
434
-
435
- /* ADR-0104 decision 4: off the middle of its room is said at release, never refused. */
436
- it('an origin off the middle of the room is a warning, and the release goes on', () => {
437
- let asset = createFigureAsset('Door')
438
- asset = addFigureBox(asset, { id: 'leaf', size: { x: 100, y: 200, z: 40 }, position: { x: 50, y: 100, z: 0 } })
439
- asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset), placement: 'floor' })
440
- const result = inspectSource(serializeFigureAsset(asset), null, undefined, 'DOOR')
441
- const off = result.findings.find(f => f.code === 'origin-off-centre')!
442
- expect(off.blocking).toBe(false)
443
- expect(off.message).toMatch(/X 50 mm/)
444
- expect(result.findings.filter(f => f.blocking).map(f => f.code)).not.toContain('origin-off-centre')
445
- expect(inspectSource(serializeFigureAsset(figureAsset()), null, undefined, 'MIXER').findings.map(f => f.code)).not.toContain('origin-off-centre')
446
- })
447
367
  })
@@ -4,7 +4,7 @@
4
4
  * (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
5
5
  * that does not is refused by name, because a source the server cannot identify is one it must not save.
6
6
  */
7
- import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figurePlaneGapOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf, forgetRecordedInstanceSize, recordFigureInstanceSize } from '@hatiolab/figure-model'
7
+ import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figurePlaneGapOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf, forgetRecordedInstanceSize, recordFigureInstanceSize } from '@hatiolab/figure-model'
8
8
  import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
9
9
 
10
10
  import type { FigureFinding } from './figure-type.js'
@@ -62,8 +62,8 @@ export interface FigureReleaseCheckInput {
62
62
 
63
63
  /**
64
64
  * A check an app adds to releasing (LV figure-server design G10): findings in the gate's own shape, blocking or said.
65
- * The LV figure-server refuses an origin off the middle of the room, which operato-figure only says (ADR-0104
66
- * decision 4: refusing is the receiving side's), and says its carrier and budget advice.
65
+ * The LV figure-server refuses an origin off the middle of the floor, a receiving side's check (ADR-0104 decision 4),
66
+ * and says its carrier and budget advice.
67
67
  */
68
68
  export type FigureReleaseCheck = (input: FigureReleaseCheckInput) => GateFinding[]
69
69
 
@@ -162,7 +162,7 @@ export type Translate = (key: string, options?: Record<string, unknown>) => stri
162
162
  * The words are the modeller's (ADR-0097 §9 Q2 A): 「차지하는 공간」 for the volume a figure takes up, 「부품에 맞추기」 for
163
163
  * setting it from the parts, and 「장착면」 for the face it stands on (`figure.text.mounting-face-explained`). A fact, then the way out.
164
164
  */
165
- function figureWording(violation: FigureViolation, say: Say, hasParts: boolean): Pick<GateFinding, 'message' | 'why' | 'how'> {
165
+ function figureWording(violation: FigureViolation, say: Say): Pick<GateFinding, 'message' | 'why' | 'how'> {
166
166
  const part = violation.part ?? ''
167
167
  const axis = (violation.axis ?? '').toUpperCase()
168
168
  const mm = violation.mm ?? 0
@@ -170,31 +170,6 @@ function figureWording(violation: FigureViolation, say: Say, hasParts: boolean):
170
170
  const key = (field: string) => `figure.gate.${violation.code.toLowerCase().replace(/_/g, '-')}.${field}`
171
171
 
172
172
  switch (violation.code) {
173
- case 'PART_OUTSIDE_OCCUPANCY':
174
- return {
175
- message: say(key('message'), { part, axis, mm }, `'${part}' goes ${mm} mm past the space the figure takes on ${axis}.`),
176
- why: say(key('why'), {}, 'The space the figure takes sets the scale it is placed at. What lies past it is left out of that scale, so on a board this part lands outside the place given to the figure.'),
177
- how: say(key('how'), { part, axis, mm }, `Use "fit to parts" to fit the space the figure takes to the parts again, or move '${part}' ${mm} mm inward on ${axis}.`)
178
- }
179
- case 'OCCUPANCY_UNDECLARED':
180
- return violation.axis
181
- ? {
182
- message: say(`figure.gate.occupancy-empty-axis.message`, { axis }, `The space the figure takes is empty on ${axis}.`),
183
- why: say(key('why'), {}, 'The space a figure takes sets the scale it is placed at on a board.'),
184
- how: say(key('how'), {}, 'Use "fit to parts" to set the space the figure takes from the parts as they are now.')
185
- }
186
- : {
187
- message: say(key('message'), {}, 'The space this figure takes is not set yet.'),
188
- why: say(key('why'), {}, 'The space a figure takes sets the scale it is placed at on a board.'),
189
- /*
190
- A new figure is refused this way before it has a part, and 「fit to parts」 has nothing to measure then —
191
- the screen does not offer it. Telling the author to press it sent them looking for a button that was not
192
- there (:3300, 2026-09-24).
193
- */
194
- how: hasParts
195
- ? say(key('how'), {}, 'Use "fit to parts" to set the space the figure takes from the parts as they are now.')
196
- : say(key('how-no-parts'), {}, 'Add a shape first, then use "fit to parts" to set the space the figure takes from it.')
197
- }
198
173
  case 'PARTS_OFF_PLACEMENT_FACE':
199
174
  return {
200
175
  message: say(key('message'), { part, mm }, `The mounting face of '${part}' is ${mm} mm off the plane the figure stands on.`),
@@ -270,12 +245,9 @@ export function inspectSource(
270
245
 
271
246
  let inspection
272
247
  let asset: FigureAsset | undefined
273
- let hasParts = true
274
248
  try {
275
249
  asset = parseFigureAsset(sourceJson)
276
250
  inspection = inspectFigureAsset(asset)
277
- const cost = figureCostOf(asset)
278
- hasParts = cost.groups + cost.hidden > 0
279
251
  } catch (e) {
280
252
  const error = e as FigureContractError & { path?: string }
281
253
  return {
@@ -287,7 +259,7 @@ export function inspectSource(
287
259
  const say = sayerOf(t)
288
260
  const findings: GateFinding[] = inspection.violations.map(violation => ({
289
261
  code: violation.code.toLowerCase().replace(/_/g, '-'),
290
- ...figureWording(violation, say, hasParts),
262
+ ...figureWording(violation, say),
291
263
  at: violation.part,
292
264
  blocking: true
293
265
  }))
@@ -305,38 +277,6 @@ export function inspectSource(
305
277
  blocking: true
306
278
  })
307
279
  }
308
- if (inspection.coverage.method === 'sampled') {
309
- const states = inspection.coverage.states
310
- findings.push({
311
- code: 'state-range-sampled',
312
- /* Sizes as well as motion: the instance size range is swept for every figure (figure-model, designer 2026-09-27). */
313
- message: say('figure.gate.state-range-sampled.message', { states }, `The whole range of sizes and motion could not be proven, so ${states} combinations of size and pose were checked.`),
314
- why: say('figure.gate.state-range-sampled.why', {}, 'Between the combinations that were checked, a part may still go past the space the figure takes.'),
315
- how: say('figure.gate.state-range-sampled.how', {}, 'To cover the whole range, re-author what could not be proven.'),
316
- /*
317
- Which motions stopped the proof, by the name a person gave them: the kernel reports each operation it could
318
- not prove (`j2-shoulder.chain: compose@1 turns …`), and the screen showed that English list as it was. The
319
- name before the first dot is the joint or part; the operations stay in the logs.
320
- */
321
- at: [...new Set((inspection.coverage.unprovable ?? []).map(entry => entry.split(':')[0]!.split('.')[0]!.trim()).filter(Boolean))].join(' · ') || undefined,
322
- blocking: false
323
- })
324
- }
325
- /*
326
- Said, never refused (ADR-0104 decision 4): the origin is where the figure is fastened, and a door fastened at its
327
- hinge is a right model. A drawing that places figures by the middle of their room reads this before it pins one.
328
- */
329
- const origin = figureOriginCentredOf(asset!)
330
- if (origin && !origin.centred) {
331
- const off = origin.off.map(o => `${o.axis.toUpperCase()} ${o.mm} mm`).join(', ')
332
- findings.push({
333
- code: 'origin-off-centre',
334
- message: say('figure.gate.origin-off-centre.message', { off }, `The origin is off the middle of the declared room: ${off}.`),
335
- why: say('figure.gate.origin-off-centre.why', {}, 'A drawing that places figures by the middle of their room puts this one that far from where it is drawn.'),
336
- how: say('figure.gate.origin-off-centre.how', {}, 'Keep it when the figure is fastened there, as a door at its hinge. Move the origin to the middle to place it by its room.'),
337
- blocking: false
338
- })
339
- }
340
280
  findings.push(...planeGapFindings(asset!, say))
341
281
  if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
342
282
  findings.push(...appFindings({ asset: asset!, type: rowType, category: rowCategory, t }))
@@ -374,7 +314,7 @@ const PLANE_ENGLISH = {
374
314
  }
375
315
  } as const
376
316
  function planeGapFindings(asset: FigureAsset, say: Say): GateFinding[] {
377
- if (asset.occupancy?.contact) return []
317
+ if (asset.mountingFace) return []
378
318
  const gap = figurePlaneGapOf(asset)
379
319
  if (!gap || gap.touches) return []
380
320
  const english = PLANE_ENGLISH[gap.placement]
@@ -550,10 +490,15 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
550
490
  why: say(key('why'), {}, 'It adds what the figure can do. Placed instances stand as they are.')
551
491
  }
552
492
  /* Said, never refused (ADR-0104): a consumer that pins a release reads these before it moves to this one. */
553
- case 'occupancy-changed':
493
+ case 'size-changed':
494
+ return {
495
+ message: say(key('message'), v, `The figure's size changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
496
+ why: say(key('why'), {}, 'A board gives the figure a box of this size. One that moves to this release sees its box change.')
497
+ }
498
+ case 'motion-range-changed':
554
499
  return {
555
- message: say(key('message'), v, `The room the figure takes changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
556
- why: say(key('why'), {}, 'A consumer places the figure by this room. One that moves to this release sees it take the new room.')
500
+ message: say(key('message'), v, `The room the figure sweeps as it moves changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
501
+ why: say(key('why'), {}, 'It is what the figure reaches when it moves. One that moves to this release sees it reach differently.')
557
502
  }
558
503
  case 'contact-face-changed':
559
504
  return {
@@ -13,7 +13,6 @@ import {
13
13
  applyFigureAuthoring,
14
14
  createFigureAsset,
15
15
  placeholderType,
16
- proposeFigureOccupancy,
17
16
  serializeFigureAsset
18
17
  } from '@hatiolab/figure-model'
19
18
 
@@ -37,11 +36,11 @@ function loadCompiled(modulePath: string): any {
37
36
  return require(resolved)
38
37
  }
39
38
 
40
- /** A figure that passes the gate on its own: a box standing on the floor, with its space declared. */
39
+ /** A figure that passes the gate on its own: a box standing on the floor. */
41
40
  function sound(): string {
42
41
  let asset = createFigureAsset('Probe')
43
42
  asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
44
- asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' })
43
+ asset = applyFigureAuthoring(asset, { kind: 'set-placement', placement: 'floor' })
45
44
  return serializeFigureAsset(asset)
46
45
  }
47
46
 
@@ -102,12 +101,14 @@ describe('inspectFigureDraft — the verdict while editing', () => {
102
101
  it('judges the unsaved source, not the stored one, and still reads the row type', async () => {
103
102
  const figure = await save(placeholderType())
104
103
  let edited = createFigureAsset('Probe')
105
- edited = addFigureBox(edited, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
104
+ edited = addFigureBox(edited, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 150, z: 0 } })
105
+ /* Its bottom named as the face it is mounted on, 100 mm above the floor: a finding the stored one does not have. */
106
+ edited = applyFigureAuthoring(edited, { kind: 'set-mounting-face', part: 'body', face: 'bottom' })
106
107
 
107
108
  const inspection = await new FigureQuery().inspectFigureDraft(serializeFigureAsset(edited), context(), figure.id)
108
109
  const codes = inspection.findings.map((f: any) => f.code)
109
110
 
110
- expect(codes).toContain('occupancy-undeclared')
111
+ expect(codes).toContain('parts-off-placement-face')
111
112
  expect(codes).toContain('type-is-placeholder')
112
113
  })
113
114
 
@@ -43,7 +43,7 @@ function mixerDraft(): FigureAsset {
43
43
  { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } },
44
44
  /* The body's front is its curved side: a point on it, by the outward normal there (ADR-0140 3.9; push Y, +Z is 270°). */
45
45
  { kind: 'attach', part: 'port', to: { part: 'body', surface: { around: 270, along: 'centre' } } },
46
- { kind: 'fit-room', placement: 'floor' }
46
+ { kind: 'set-placement', placement: 'floor' }
47
47
  ] as any)
48
48
  }
49
49
 
@@ -10,7 +10,7 @@ const JSZip = require('jszip')
10
10
  const shell = require('@things-factory/shell')
11
11
  const authBase = require('@things-factory/auth-base')
12
12
  const settingBase = require('@things-factory/setting-base')
13
- const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, readFigurePackage } = require('@hatiolab/figure-model')
13
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, readFigurePackage } = require('@hatiolab/figure-model')
14
14
 
15
15
  let dataSource: DataSource | null = null
16
16
  let Figure: any
@@ -60,7 +60,7 @@ afterAll(async () => {
60
60
  const releasable = (label = 'Conveyor') => {
61
61
  let a = addFigureBox(createFigureAsset(label), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
62
62
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
63
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
63
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
64
64
  }
65
65
 
66
66
  let seq = 0