@things-factory/figure-service 10.1.205 → 10.1.207

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 (105) hide show
  1. package/dist-server/service/figure/event-subscriber.js +5 -0
  2. package/dist-server/service/figure/event-subscriber.js.map +1 -1
  3. package/dist-server/service/figure/figure-context.js +0 -2
  4. package/dist-server/service/figure/figure-context.js.map +1 -1
  5. package/dist-server/service/figure/figure-format.d.ts +9 -2
  6. package/dist-server/service/figure/figure-format.js +36 -69
  7. package/dist-server/service/figure/figure-format.js.map +1 -1
  8. package/dist-server/service/figure/figure-mutation.js +2 -0
  9. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  10. package/dist-server/service/figure/figure-propose-steps.d.ts +1 -1
  11. package/dist-server/service/figure/figure-propose-steps.js +3 -5
  12. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  13. package/dist-server/service/figure/figure-query.js +3 -5
  14. package/dist-server/service/figure/figure-query.js.map +1 -1
  15. package/dist-server/service/figure/figure-revert.js +1 -1
  16. package/dist-server/service/figure/figure-revert.js.map +1 -1
  17. package/dist-server/service/figure/figure-type.js +1 -1
  18. package/dist-server/service/figure/figure-type.js.map +1 -1
  19. package/dist-server/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +8 -8
  21. package/recipes/base-frame/recipe.json +4 -8
  22. package/recipes/belt-surface/recipe.json +2 -4
  23. package/recipes/bin-row/recipe.json +1 -2
  24. package/recipes/bollard-row/recipe.json +1 -2
  25. package/recipes/cabinet-doors/recipe.json +5 -10
  26. package/recipes/cable-tray/recipe.json +3 -6
  27. package/recipes/canopy/recipe.json +2 -4
  28. package/recipes/cart-handle/recipe.json +1 -2
  29. package/recipes/ceiling-rail-carriage/recipe.json +1 -2
  30. package/recipes/chain-rails/recipe.json +2 -4
  31. package/recipes/column-row/recipe.json +1 -2
  32. package/recipes/conveyor-skirts/recipe.json +4 -8
  33. package/recipes/double-hinged-door/recipe.json +6 -12
  34. package/recipes/drawer-stack/recipe.json +13 -26
  35. package/recipes/drip-tray/recipe.json +2 -4
  36. package/recipes/end-covers/recipe.json +4 -8
  37. package/recipes/ffu-row/recipe.json +1 -2
  38. package/recipes/floor-zone/recipe.json +4 -8
  39. package/recipes/folding-door/recipe.json +3 -6
  40. package/recipes/gantry-frame/recipe.json +1 -2
  41. package/recipes/guard-cover/recipe.json +2 -4
  42. package/recipes/handrail/recipe.json +2 -4
  43. package/recipes/hatch/recipe.json +4 -8
  44. package/recipes/hinged-door/recipe.json +4 -8
  45. package/recipes/hopper/recipe.json +1 -2
  46. package/recipes/ladder/recipe.json +3 -6
  47. package/recipes/lift-updown/recipe.json +2 -4
  48. package/recipes/linear-guide/recipe.json +1 -2
  49. package/recipes/locker-row/recipe.json +1 -2
  50. package/recipes/observation-window/recipe.json +4 -8
  51. package/recipes/operator-panel/recipe.json +4 -8
  52. package/recipes/partition-wall/recipe.json +3 -6
  53. package/recipes/peg-board/facts.md +1 -1
  54. package/recipes/peg-board/recipe.json +1 -2
  55. package/recipes/pipe-run/recipe.json +1 -2
  56. package/recipes/platform/recipe.json +2 -4
  57. package/recipes/pull-cord/recipe.json +1 -2
  58. package/recipes/roll-up-shutter/recipe.json +3 -6
  59. package/recipes/roller-row/recipe.json +2 -4
  60. package/recipes/room-shell/recipe.json +6 -12
  61. package/recipes/safety-fence/recipe.json +2 -4
  62. package/recipes/safety-mat/recipe.json +1 -2
  63. package/recipes/shelf-tiers/recipe.json +5 -10
  64. package/recipes/side-guides/recipe.json +2 -4
  65. package/recipes/sign-board/recipe.json +1 -2
  66. package/recipes/sliding-door/recipe.json +7 -14
  67. package/recipes/stairs/recipe.json +1 -2
  68. package/recipes/stopper/recipe.json +1 -2
  69. package/recipes/suction-pads/recipe.json +1 -2
  70. package/recipes/tank/recipe.json +2 -4
  71. package/recipes/toe-board/recipe.json +1 -2
  72. package/recipes/traffic-cones/recipe.json +1 -2
  73. package/recipes/transfer-belts/recipe.json +2 -4
  74. package/recipes/trough/recipe.json +1 -2
  75. package/recipes/turntable/recipe.json +1 -2
  76. package/recipes/workbench-top/recipe.json +2 -4
  77. package/scripts/templates.test.mts +2 -3
  78. package/server/service/figure/event-subscriber.ts +4 -0
  79. package/server/service/figure/figure-blueprint-query.test.ts +3 -0
  80. package/server/service/figure/figure-category-db.test.ts +2 -2
  81. package/server/service/figure/figure-context.test.ts +2 -2
  82. package/server/service/figure/figure-context.ts +0 -2
  83. package/server/service/figure/figure-deprecation-db.test.ts +2 -2
  84. package/server/service/figure/figure-format.test.ts +25 -105
  85. package/server/service/figure/figure-format.ts +40 -73
  86. package/server/service/figure/figure-inspect-db.test.ts +6 -5
  87. package/server/service/figure/figure-llm-smoke.test.ts +1 -1
  88. package/server/service/figure/figure-mutation.ts +2 -0
  89. package/server/service/figure/figure-namespace-db.test.ts +2 -2
  90. package/server/service/figure/figure-propose-steps.test.ts +15 -20
  91. package/server/service/figure/figure-propose-steps.ts +3 -5
  92. package/server/service/figure/figure-public-db.test.ts +2 -2
  93. package/server/service/figure/figure-query.ts +3 -6
  94. package/server/service/figure/figure-release-check.test.ts +2 -2
  95. package/server/service/figure/figure-release-record-db.test.ts +7 -6
  96. package/server/service/figure/figure-release-size-db.test.ts +156 -0
  97. package/server/service/figure/figure-revert.ts +2 -2
  98. package/server/service/figure/figure-tools.test.ts +2 -2
  99. package/server/service/figure/figure-type.ts +1 -1
  100. package/translations/en.json +4 -16
  101. package/translations/es.json +4 -16
  102. package/translations/ja.json +4 -16
  103. package/translations/ko.json +4 -16
  104. package/translations/ms.json +4 -16
  105. package/translations/zh.json +4 -16
@@ -81,8 +81,7 @@
81
81
  "param": "pitch"
82
82
  }
83
83
  }
84
- },
85
- "narrowRange": true
84
+ }
86
85
  },
87
86
  {
88
87
  "kind": "set-joint",
@@ -65,8 +65,7 @@
65
65
  "of": "part",
66
66
  "part": "{target}",
67
67
  "axis": "z"
68
- },
69
- "narrowRange": true
68
+ }
70
69
  },
71
70
  {
72
71
  "kind": "link-dimension",
@@ -76,8 +75,7 @@
76
75
  "of": "part",
77
76
  "part": "{target}",
78
77
  "axis": "z"
79
- },
80
- "narrowRange": true
78
+ }
81
79
  },
82
80
  {
83
81
  "kind": "set-joint",
@@ -42,8 +42,7 @@
42
42
  "of": "part",
43
43
  "part": "{target}",
44
44
  "axis": "x"
45
- },
46
- "narrowRange": true
45
+ }
47
46
  },
48
47
  {
49
48
  "kind": "set-joint",
@@ -66,8 +66,7 @@
66
66
  },
67
67
  "times": {
68
68
  "param": "size"
69
- },
70
- "narrowRange": true
69
+ }
71
70
  },
72
71
  {
73
72
  "kind": "set-joint",
@@ -55,8 +55,7 @@
55
55
  "of": "part",
56
56
  "part": "{target}",
57
57
  "axis": "x"
58
- },
59
- "narrowRange": true
58
+ }
60
59
  },
61
60
  {
62
61
  "kind": "link-dimension",
@@ -66,8 +65,7 @@
66
65
  "of": "part",
67
66
  "part": "{target}",
68
67
  "axis": "z"
69
- },
70
- "narrowRange": true
68
+ }
71
69
  },
72
70
  {
73
71
  "kind": "set-joint",
@@ -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}`)
@@ -12,6 +12,8 @@ import { FigureHistory } from './figure-history.js'
12
12
  * 그 전부를 남기면 판본 목록이 「내가 오늘 몇 번 눌렀나」가 된다. 사람이 판이라고 부르는 것은
13
13
  * **발행한 것**이므로 그때만 베낀다. board 가 같은 규칙으로 돈다(`board-service` 의 같은 이름 파일).
14
14
  *
15
+ * 발행판의 정본(`__releaseSource`)은 크기 범위를 적은 발행판 사본이다 — Figure 행에는 작성자가 좁힌 끝만 남는다.
16
+ *
15
17
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
18
  * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
19
  * (`__releaseFormat`), 잰 커널의 판(`__releaseKernel`, ADR-0109 C1), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
@@ -20,6 +22,8 @@ import { FigureHistory } from './figure-history.js'
20
22
  /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
21
23
  function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
22
24
  const e = entity as any
25
+ /* What a board reads of this release: its own copy, with the size range written down (ADR-0087 decision 6). */
26
+ if (e.__releaseSource) history.source = e.__releaseSource
23
27
  ;(history as any).comment = e.__releaseComment ?? null
24
28
  history.assetFormat = e.__releaseFormat ?? null
25
29
  history.kernelVersion = e.__releaseKernel ?? null
@@ -44,6 +44,8 @@ beforeAll(async () => {
44
44
  Figure = loadCompiled('service/figure/figure').Figure
45
45
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
46
46
  FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
47
+ /* A released row is saved with its history row, as releasing does: a board reads the release's own copy there (ADR-0087 decision 6). */
48
+ const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
47
49
 
48
50
  dataSource = new TypeOrmDataSource({
49
51
  type: 'sqlite',
@@ -52,6 +54,7 @@ beforeAll(async () => {
52
54
  dropSchema: true,
53
55
  namingStrategy: new shell.NamingStrategy(),
54
56
  entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
57
+ subscribers: [Subscriber],
55
58
  logging: false
56
59
  })
57
60
 
@@ -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() {
@@ -193,10 +193,10 @@ describe('a proposal changes only what it was shown', () => {
193
193
  it('a shown part may change, a new part may be fastened to any part, the figure as a whole may change', () => {
194
194
  const steps = [
195
195
  { kind: 'set-material', target: 'shelf-2', material: { token: 'palette.primary' } },
196
- { kind: 'add-part', part: { id: 'lamp', kind: 'sphere', dimensions: { radiusX: 40, radiusY: 40, radiusZ: 40 } } },
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
  })
@@ -1,9 +1,10 @@
1
1
  /*
2
- * The doors a stored source goes through: saving (`parseSource`), publishing (`inspectSource`) and handing it to a
3
- * board (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
2
+ * The doors a stored source goes through: saving (`parseSource`), publishing (`inspectSource`, and the release's own
3
+ * copy in `releaseRecordOf`), bringing a release back as a draft (`draftSourceOfRelease`) and handing it to a board
4
+ * (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
4
5
  * that does not is refused by name, because a source the server cannot identify is one it must not save.
5
6
  */
6
- import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figurePlaneGapOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } 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'
7
8
  import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
8
9
 
9
10
  import type { FigureFinding } from './figure-type.js'
@@ -61,8 +62,8 @@ export interface FigureReleaseCheckInput {
61
62
 
62
63
  /**
63
64
  * A check an app adds to releasing (LV figure-server design G10): findings in the gate's own shape, blocking or said.
64
- * The LV figure-server refuses an origin off the middle of the room, which operato-figure only says (ADR-0104
65
- * 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.
66
67
  */
67
68
  export type FigureReleaseCheck = (input: FigureReleaseCheckInput) => GateFinding[]
68
69
 
@@ -161,7 +162,7 @@ export type Translate = (key: string, options?: Record<string, unknown>) => stri
161
162
  * The words are the modeller's (ADR-0097 §9 Q2 A): 「차지하는 공간」 for the volume a figure takes up, 「부품에 맞추기」 for
162
163
  * setting it from the parts, and 「장착면」 for the face it stands on (`figure.text.mounting-face-explained`). A fact, then the way out.
163
164
  */
164
- 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'> {
165
166
  const part = violation.part ?? ''
166
167
  const axis = (violation.axis ?? '').toUpperCase()
167
168
  const mm = violation.mm ?? 0
@@ -169,31 +170,6 @@ function figureWording(violation: FigureViolation, say: Say, hasParts: boolean):
169
170
  const key = (field: string) => `figure.gate.${violation.code.toLowerCase().replace(/_/g, '-')}.${field}`
170
171
 
171
172
  switch (violation.code) {
172
- case 'PART_OUTSIDE_OCCUPANCY':
173
- return {
174
- message: say(key('message'), { part, axis, mm }, `'${part}' goes ${mm} mm past the space the figure takes on ${axis}.`),
175
- 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.'),
176
- 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}.`)
177
- }
178
- case 'OCCUPANCY_UNDECLARED':
179
- return violation.axis
180
- ? {
181
- message: say(`figure.gate.occupancy-empty-axis.message`, { axis }, `The space the figure takes is empty on ${axis}.`),
182
- why: say(key('why'), {}, 'The space a figure takes sets the scale it is placed at on a board.'),
183
- how: say(key('how'), {}, 'Use "fit to parts" to set the space the figure takes from the parts as they are now.')
184
- }
185
- : {
186
- message: say(key('message'), {}, 'The space this figure takes is not set yet.'),
187
- why: say(key('why'), {}, 'The space a figure takes sets the scale it is placed at on a board.'),
188
- /*
189
- A new figure is refused this way before it has a part, and 「fit to parts」 has nothing to measure then —
190
- the screen does not offer it. Telling the author to press it sent them looking for a button that was not
191
- there (:3300, 2026-09-24).
192
- */
193
- how: hasParts
194
- ? say(key('how'), {}, 'Use "fit to parts" to set the space the figure takes from the parts as they are now.')
195
- : say(key('how-no-parts'), {}, 'Add a shape first, then use "fit to parts" to set the space the figure takes from it.')
196
- }
197
173
  case 'PARTS_OFF_PLACEMENT_FACE':
198
174
  return {
199
175
  message: say(key('message'), { part, mm }, `The mounting face of '${part}' is ${mm} mm off the plane the figure stands on.`),
@@ -269,12 +245,9 @@ export function inspectSource(
269
245
 
270
246
  let inspection
271
247
  let asset: FigureAsset | undefined
272
- let hasParts = true
273
248
  try {
274
249
  asset = parseFigureAsset(sourceJson)
275
250
  inspection = inspectFigureAsset(asset)
276
- const cost = figureCostOf(asset)
277
- hasParts = cost.groups + cost.hidden > 0
278
251
  } catch (e) {
279
252
  const error = e as FigureContractError & { path?: string }
280
253
  return {
@@ -286,7 +259,7 @@ export function inspectSource(
286
259
  const say = sayerOf(t)
287
260
  const findings: GateFinding[] = inspection.violations.map(violation => ({
288
261
  code: violation.code.toLowerCase().replace(/_/g, '-'),
289
- ...figureWording(violation, say, hasParts),
262
+ ...figureWording(violation, say),
290
263
  at: violation.part,
291
264
  blocking: true
292
265
  }))
@@ -304,38 +277,6 @@ export function inspectSource(
304
277
  blocking: true
305
278
  })
306
279
  }
307
- if (inspection.coverage.method === 'sampled') {
308
- const states = inspection.coverage.states
309
- findings.push({
310
- code: 'state-range-sampled',
311
- /* Sizes as well as motion: the instance size range is swept for every figure (figure-model, designer 2026-09-27). */
312
- 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.`),
313
- 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.'),
314
- how: say('figure.gate.state-range-sampled.how', {}, 'To cover the whole range, re-author what could not be proven.'),
315
- /*
316
- Which motions stopped the proof, by the name a person gave them: the kernel reports each operation it could
317
- not prove (`j2-shoulder.chain: compose@1 turns …`), and the screen showed that English list as it was. The
318
- name before the first dot is the joint or part; the operations stay in the logs.
319
- */
320
- at: [...new Set((inspection.coverage.unprovable ?? []).map(entry => entry.split(':')[0]!.split('.')[0]!.trim()).filter(Boolean))].join(' · ') || undefined,
321
- blocking: false
322
- })
323
- }
324
- /*
325
- Said, never refused (ADR-0104 decision 4): the origin is where the figure is fastened, and a door fastened at its
326
- hinge is a right model. A drawing that places figures by the middle of their room reads this before it pins one.
327
- */
328
- const origin = figureOriginCentredOf(asset!)
329
- if (origin && !origin.centred) {
330
- const off = origin.off.map(o => `${o.axis.toUpperCase()} ${o.mm} mm`).join(', ')
331
- findings.push({
332
- code: 'origin-off-centre',
333
- message: say('figure.gate.origin-off-centre.message', { off }, `The origin is off the middle of the declared room: ${off}.`),
334
- 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.'),
335
- 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.'),
336
- blocking: false
337
- })
338
- }
339
280
  findings.push(...planeGapFindings(asset!, say))
340
281
  if (releasedJson) findings.push(...figureContractFindings(asset!, releasedJson, say))
341
282
  findings.push(...appFindings({ asset: asset!, type: rowType, category: rowCategory, t }))
@@ -373,7 +314,7 @@ const PLANE_ENGLISH = {
373
314
  }
374
315
  } as const
375
316
  function planeGapFindings(asset: FigureAsset, say: Say): GateFinding[] {
376
- if (asset.occupancy?.contact) return []
317
+ if (asset.mountingFace) return []
377
318
  const gap = figurePlaneGapOf(asset)
378
319
  if (!gap || gap.touches) return []
379
320
  const english = PLANE_ENGLISH[gap.placement]
@@ -549,10 +490,15 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
549
490
  why: say(key('why'), {}, 'It adds what the figure can do. Placed instances stand as they are.')
550
491
  }
551
492
  /* Said, never refused (ADR-0104): a consumer that pins a release reads these before it moves to this one. */
552
- 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':
553
499
  return {
554
- message: say(key('message'), v, `The room the figure takes changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
555
- 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.')
556
502
  }
557
503
  case 'contact-face-changed':
558
504
  return {
@@ -711,9 +657,20 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
711
657
  export function releaseRecordOf(
712
658
  sourceJson: string,
713
659
  releasedJson?: string | null
714
- ): { format: string; kernel: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
660
+ ): { source: string; format: string; kernel: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
715
661
  const asset = parseFigureAsset(sourceJson)
716
- const measured = { format: asset.version, kernel: FIGURE_KERNEL_VERSION, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
662
+ /*
663
+ The release's own copy carries its size range as it is now: the author's ends, and the rest as far as the parts
664
+ allow (ADR-0087 decision 6). A board reads the ends from it and does not measure; the figure's row keeps only what
665
+ the author narrowed.
666
+ */
667
+ const measured = {
668
+ source: serializeFigureAsset(recordFigureInstanceSize(asset)),
669
+ format: asset.version,
670
+ kernel: FIGURE_KERNEL_VERSION,
671
+ metrics: figureReleaseMetricsOf(asset),
672
+ topView: figureTopViewOf(asset, null)
673
+ }
717
674
  if (!releasedJson) return { ...measured, changes: [] }
718
675
  let before: FigureAsset
719
676
  try {
@@ -724,6 +681,16 @@ export function releaseRecordOf(
724
681
  return { ...measured, changes: figureContractChanges(before, asset) }
725
682
  }
726
683
 
684
+ /**
685
+ * A release's source made a draft's again (a revert): the size ends its release wrote down, as far as the parts allow,
686
+ * are opened, and the author's own are kept (ADR-0087 decision 6). An author's end that equals the computed one is
687
+ * opened with them.
688
+ */
689
+ export function draftSourceOfRelease(releasedJson: string): string {
690
+ assertFormat(releasedJson)
691
+ return serializeFigureAsset(forgetRecordedInstanceSize(parseFigureAsset(releasedJson)))
692
+ }
693
+
727
694
  /**
728
695
  * What a consumer is handed for a released figure: the asset itself, because what draws it -- `registerFigure` in
729
696
  * things-scene -- takes the asset and compiles it with the kernel.