@things-factory/figure-ui 10.1.161 → 10.1.163

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 (31) hide show
  1. package/client/modeller/data-interface.ts +235 -67
  2. package/client/modeller/editor.ts +3 -3
  3. package/client/modeller/figure-data-panel.ts +602 -193
  4. package/client/modeller/figure-preview.ts +78 -26
  5. package/client/modeller/refusals.ts +18 -14
  6. package/dist-client/modeller/data-interface.d.ts +59 -27
  7. package/dist-client/modeller/data-interface.js +237 -60
  8. package/dist-client/modeller/data-interface.js.map +1 -1
  9. package/dist-client/modeller/editor.d.ts +1 -1
  10. package/dist-client/modeller/editor.js +2 -2
  11. package/dist-client/modeller/editor.js.map +1 -1
  12. package/dist-client/modeller/figure-data-panel.d.ts +35 -13
  13. package/dist-client/modeller/figure-data-panel.js +598 -186
  14. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  15. package/dist-client/modeller/figure-preview.d.ts +10 -3
  16. package/dist-client/modeller/figure-preview.js +81 -26
  17. package/dist-client/modeller/figure-preview.js.map +1 -1
  18. package/dist-client/modeller/refusals.d.ts +2 -2
  19. package/dist-client/modeller/refusals.js +24 -18
  20. package/dist-client/modeller/refusals.js.map +1 -1
  21. package/dist-client/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +6 -6
  23. package/test/browser/product-page.check.mjs +1 -1
  24. package/test/browser/screen.check.mjs +7 -7
  25. package/test/data-motion.test.ts +143 -50
  26. package/translations/en.json +109 -43
  27. package/translations/es.json +109 -43
  28. package/translations/ja.json +109 -43
  29. package/translations/ko.json +109 -43
  30. package/translations/ms.json +109 -43
  31. package/translations/zh.json +109 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.161",
3
+ "version": "10.1.163",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.6.5",
31
- "@hatiolab/things-scene": "^10.1.85",
30
+ "@hatiolab/figure-model": "^0.6.6",
31
+ "@hatiolab/things-scene": "^10.1.86",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -42,10 +42,10 @@
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
44
  "@things-factory/ai-assistant": "^10.1.161",
45
- "@things-factory/figure-service": "^10.1.161",
46
- "@things-factory/figure-viewer": "^10.1.155",
45
+ "@things-factory/figure-service": "^10.1.163",
46
+ "@things-factory/figure-viewer": "^10.1.163",
47
47
  "@things-factory/setting-base": "^10.1.159",
48
48
  "three": "^0.185.1"
49
49
  },
50
- "gitHead": "d32fe0614ea40572787acd94e307cc18a681ab38"
50
+ "gitHead": "fe2a9c39294d64cd50e52b283105390bb0fa6cee"
51
51
  }
@@ -698,7 +698,7 @@ describe('the catalogue preview', () => {
698
698
  product.forget()
699
699
  product.openDetail('a')
700
700
  })
701
- await press('figure-detail-dock >>> footer button', 'edit')
701
+ await press('figure-detail-dock >>> button[edit]', 'edit')
702
702
  await until(() => product.navigations().length, 'the modeller opened')
703
703
  assert.deepEqual(await read(() => product.navigations().map(({ path, detailShown }) => ({ path, detailShown }))), [{ path: '/figure-modeller/a', detailShown: false }])
704
704
  })
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The modeller's screen in a real browser: its frame (three panes, the side's six tabs, the bar over the work), a new
2
+ * The modeller's screen in a real browser: its frame (three panes, the side's seven tabs, the bar over the work), a new
3
3
  * figure's first minute, the side panel as parts are chosen, the view tab, the preview, the verdict tab and the card's
4
4
  * picture -- each read as a person meets it, after doing what a person does (a click, a key, a narrower window).
5
5
  *
@@ -150,7 +150,7 @@ describe('the verdict tab over a figure, as the modeller page mounts it', () =>
150
150
  })
151
151
  })
152
152
 
153
- describe('the frame: parts · canvas · one side of six tabs, and the bar over the work', () => {
153
+ describe('the frame: parts · canvas · one side of seven tabs, and the bar over the work', () => {
154
154
  before(() => mount('box'))
155
155
 
156
156
  test('three panes: the parts list on the left, the canvas in the middle, the side on the right -- one side, nothing under the canvas', async () => {
@@ -185,13 +185,13 @@ describe('the frame: parts · canvas · one side of six tabs, and the bar over t
185
185
  assert.equal(await read(() => getComputedStyle(screenCheck.editor()).containerType), 'inline-size')
186
186
  })
187
187
 
188
- test('the side\'s six tabs, in order, each saying its name under its icon -- the name it is read by', async () => {
188
+ test('the side\'s seven tabs, in order, each saying its name under its icon -- the name it is read by', async () => {
189
189
  const tabs = await read(() =>
190
190
  [...screenCheck.editor().shadowRoot.querySelectorAll('aside button[tab]')].map(b => ({ label: b.getAttribute('aria-label'), shows: b.querySelector('span[tab-label]')?.textContent.trim() }))
191
191
  )
192
192
  assert.deepEqual(
193
193
  tabs.map(t => t.label),
194
- ['properties', 'capability', 'motion', 'verdict', 'view', 'history'].map(TAB)
194
+ ['properties', 'capability', 'motion', 'data', 'verdict', 'view', 'history'].map(TAB)
195
195
  )
196
196
  for (const tab of tabs) assert.equal(tab.shows, tab.label, `${tab.label} shows its name`)
197
197
  assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelector('aside').firstElementChild.matches('div[tabs]')), true, 'the tabs are the side\'s first row')
@@ -200,15 +200,15 @@ describe('the frame: parts · canvas · one side of six tabs, and the bar over t
200
200
  test('a tab shows its panel and hides the others without taking them down -- the verdict keeps what it holds', async () => {
201
201
  const panels = () =>
202
202
  read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('aside > div[panel]')].map(p => ({ seen: p.getBoundingClientRect().height > 0, connected: p.isConnected, verdict: !!p.querySelector('slot[name=verdict]') })))
203
- const tabs = ['properties', 'capability', 'motion', 'verdict', 'view', 'history']
203
+ const tabs = ['properties', 'capability', 'motion', 'data', 'verdict', 'view', 'history']
204
204
  for (const [at, tab] of tabs.entries()) {
205
205
  await pressTab(TAB(tab))
206
206
  await until(`${tab} on`, name => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === name, TAB(tab))
207
207
  const now = await panels()
208
- assert.equal(now.length, 6, 'six panels, all standing')
208
+ assert.equal(now.length, 7, 'seven panels, all standing')
209
209
  assert.deepEqual(now.map(p => p.seen), tabs.map((_, i) => i === at), `${tab}: its panel seen, the rest hidden`)
210
210
  assert.ok(now.every(p => p.connected))
211
- assert.ok(now[3].verdict, 'the verdict panel holds the verdict slot whichever tab is on')
211
+ assert.ok(now[tabs.indexOf('verdict')].verdict, 'the verdict panel holds the verdict slot whichever tab is on')
212
212
  }
213
213
  await pressTab(TAB('properties'))
214
214
  })
@@ -1,8 +1,8 @@
1
1
  /*
2
- * **데이터 속성 · 동작 그룹** (ADR-0051 §2.2 · §3.2, 2026-10-13).
2
+ * **데이터 형식 · 연결 · 동작 그룹** (ADR-0051 §8 · §3.2, 데이터 바인딩 설계 결정 4, 2026-10-15).
3
3
  *
4
4
  * 데이터 탭과 클립 패널이 고를 수 있게 내놓는 것과, 그 초안을 figure-model 이 받는지를 로봇 개(robotDogFigure) 위에서 본다.
5
- * 규칙은 커널의 것이다 -- 여기는 목록과 초안과 문구만 시험한다.
5
+ * 규칙은 커널의 것이고 데이터 읽기는 things-scene 의 것이다 -- 여기는 목록과 초안과 문구만 시험한다.
6
6
  */
7
7
  import { test } from 'node:test'
8
8
  import assert from 'node:assert/strict'
@@ -10,10 +10,25 @@ import { readFileSync } from 'node:fs'
10
10
  import { fileURLToPath } from 'node:url'
11
11
 
12
12
  import { editFigurePart, quadrupedRobotFigure, robotDogFigure } from '@hatiolab/figure-model'
13
- import type { FigureAsset, FigureDataProperty } from '@hatiolab/figure-model'
13
+ import type { FigureAsset, FigureDataField } from '@hatiolab/figure-model'
14
14
 
15
15
  import { clipGroupsOf, freeGroupIdOf, groupEdit, undrivenOf } from '../client/modeller/clips.ts'
16
- import { connectionDraftOf, dataSampleOf, dataTargetsOf, freeDataIdOf, propertyDraftOf, sampleValueOf } from '../client/modeller/data-interface.ts'
16
+ import {
17
+ accessorOfRow,
18
+ behaviorsOf,
19
+ bindingDraftOf,
20
+ fieldAt,
21
+ fieldOfKind,
22
+ fitsField,
23
+ freeIdOf,
24
+ motionWrittenByData,
25
+ portDraftOf,
26
+ sampleOf,
27
+ schemaWithField,
28
+ schemaWithoutField,
29
+ typedValueOf,
30
+ valueRowsOf
31
+ } from '../client/modeller/data-interface.ts'
17
32
  import { editRefusalOf } from '../client/modeller/refusals.ts'
18
33
 
19
34
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
@@ -56,81 +71,159 @@ test('그룹 목록과 빈 클립 · 그룹 id 는 사람이 적은 이름에서
56
71
  assert.deepEqual([two.motionGroups![0]!.transitionSeconds, two.motionGroups![0]!.defaultClip], [0.5, 'walk'])
57
72
  })
58
73
 
59
- test('속성 종류마다 고를 수 있는 대상과, 첫 연결 초안을 커널이 받는다', () => {
60
- const kinds = (kind: FigureDataProperty['kind']) => [...new Set(dataTargetsOf(DOG, kind).map(t => t.target.kind))]
61
- assert.deepEqual(kinds('number'), ['state', 'motion-speed'])
62
- assert.deepEqual(kinds('enum'), ['state', 'palette', 'motion-clip'])
63
- let asset: FigureAsset = DOG
64
- const load: FigureDataProperty = propertyDraftOf(asset, 'load level', 'number')
65
- assert.deepEqual([load.id, load.kind], ['load-level', 'number'])
66
- asset = editFigurePart(asset, { kind: 'set-data-property', property: { ...load, unit: 'deg', min: -30, max: 30 } as FigureDataProperty })
67
- const hip = dataTargetsOf(asset, 'number').find(t => t.target.kind === 'state' && t.target.input === 'fl-hip')!
68
- const draft = connectionDraftOf(asset, asset.dataInterface!.properties.find(p => p.id === 'load-level')!, hip)
69
- assert.deepEqual(draft.transform, { kind: 'identity' }, '같은 단위는 그대로')
70
- const cadence = asset.dataInterface!.properties.find(p => p.id === 'cadence')!
71
- assert.deepEqual(connectionDraftOf(asset, cadence, hip).transform, { kind: 'affine', scale: 1, offset: 0 }, '다른 단위는 배율로 -- 단위를 짐작하지 않는다')
72
- asset = editFigurePart(asset, { kind: 'set-data-connection', connection: draft })
73
- /* 선택값 하나를 색에: 값마다 지금 색으로 시작한다. */
74
- const mode = asset.dataInterface!.properties.find(p => p.id === 'mode')!
75
- const body = dataTargetsOf(asset, 'enum').find(t => t.target.kind === 'palette' && t.target.token === 'palette.body')!
76
- const colour = connectionDraftOf(asset, mode, body)
77
- assert.deepEqual(colour.transform, { kind: 'lookup', cases: ['idle', 'walk', 'run'].map(when => ({ when, value: DOG.palette['palette.body'] })) })
78
- editFigurePart(asset, { kind: 'set-data-connection', connection: colour })
79
- assert.equal(freeDataIdOf(['mode', 'mode-2'], 'mode'), 'mode-3')
80
- assert.equal(freeDataIdOf([], '배터리'), 'data', '영문 id 가 남지 않으면 data')
74
+ /** The dog with one more top field, the way the data tab adds one. */
75
+ const withField = (asset: FigureAsset, field: FigureDataField) => editFigurePart(asset, { kind: 'set-data-schema', schema: schemaWithField(asset.dataInterface!.schema, '', field) })
76
+ const choice = (asset: FigureAsset, key: string) => behaviorsOf(asset).find(c => c.key === key)!
77
+ const draft = (asset: FigureAsset, path: string, key: string) => bindingDraftOf(asset, accessorOfRow(path), fieldAt(asset.dataInterface!.schema, path)!, choice(asset, key))
78
+
79
+ test('작동 목록: 이 Figure 가 할 수 있는 것 전부, 값의 종류마다 맞는 것만', () => {
80
+ const kinds = [...new Set(behaviorsOf(DOG).map(c => c.kind))]
81
+ assert.deepEqual(kinds, ['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'pose', 'items'])
82
+ const fits = (field: FigureDataField) => [...new Set(behaviorsOf(DOG).filter(c => fitsField(field, c)).map(c => c.kind))]
83
+ assert.deepEqual(fits({ name: 'n', kind: 'number' }), ['state', 'palette', 'motion-clip', 'motion-speed', 'status', 'pose', 'items'])
84
+ assert.deepEqual(fits({ name: 'b', kind: 'boolean' }), ['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused'])
85
+ assert.deepEqual(fits({ name: 'o', kind: 'object', fields: [] }), [])
86
+ assert.deepEqual(fits({ name: 'l', kind: 'list', item: { kind: 'object', fields: [] } }), ['items'])
87
+ })
88
+
89
+ test('첫 연결 초안: 맞으면 값 그대로, 범위·단위만 다르면 배율, 닫힌 값은 값마다 -- 커널이 받는다', () => {
90
+ let asset = withField(DOG, { name: 'lean', kind: 'number', unit: 'deg', min: -30, max: 30 })
91
+ const lean = draft(asset, 'lean', 'state:fl-hip')
92
+ assert.deepEqual([lean.id, lean.rule, lean.param], ['lean-fl-hip', 'value', undefined], '같은 단위·범위 안은 그대로')
93
+ asset = editFigurePart(asset, { kind: 'set-data-binding', binding: lean })
94
+ /* 0~100 % 를 배속 0~3 에 -- 범위를 겹쳐 놓는 배율. */
95
+ asset = withField(asset, { name: 'pct', kind: 'number', min: 0, max: 100 })
96
+ const pct = draft({ ...asset, dataInterface: { ...asset.dataInterface!, bindings: asset.dataInterface!.bindings.filter(b => b.id !== 'cadence-locomotion') } }, 'pct', 'motion-speed:locomotion')
97
+ assert.deepEqual([pct.rule, pct.param], ['scale', { scale: 0.03, offset: 0 }])
98
+ /* m 로 오는 위치는 mm 로. */
99
+ asset = withField(asset, { name: 'east', kind: 'number', unit: 'm' })
100
+ const east = draft({ ...asset, dataInterface: { ...asset.dataInterface!, bindings: asset.dataInterface!.bindings.filter(b => b.id !== 'at-x') } }, 'east', 'pose:x')
101
+ assert.deepEqual([east.rule, east.param], ['scale', { scale: 1000, offset: 0 }])
102
+ /* 닫힌 글자를 색에: 값마다 지금 색으로 시작한다. */
103
+ const body = draft(asset, 'mode', 'palette:palette.body')
104
+ assert.deepEqual([body.rule, body.param], ['map', Object.fromEntries(['idle', 'walk', 'run'].map(v => [v, DOG.palette['palette.body']]))])
105
+ editFigurePart(asset, { kind: 'set-data-binding', binding: body })
106
+ /* 열린 숫자를 색에: 구간마다, 그 밖은 지금 색. */
107
+ assert.deepEqual(draft(asset, 'lean', 'palette:palette.body').param, { default: DOG.palette['palette.body'] })
108
+ assert.equal(freeIdOf(['mode', 'mode-2'], 'mode'), 'mode-3')
109
+ assert.equal(freeIdOf([], '배터리'), 'binding', '영문 id 가 남지 않으면 binding')
110
+ })
111
+
112
+ test('아이템 연결: 목록 통째로, 요소의 id·자리 필드를 이름으로 짚는다', () => {
113
+ const asset = withField({ ...DOG, dataInterface: { ...DOG.dataInterface!, bindings: DOG.dataInterface!.bindings.filter(b => b.id !== 'back-load') } }, {
114
+ name: 'carriers',
115
+ kind: 'list',
116
+ item: { kind: 'object', fields: [{ name: 'carrierId', kind: 'text' }, { name: 'carrierType', kind: 'text' }, { name: 'slotNo', kind: 'text' }] }
117
+ })
118
+ const items = bindingDraftOf(asset, 'carriers', fieldAt(asset.dataInterface!.schema, 'carriers')!, choice(asset, 'items:'))
119
+ assert.deepEqual(items.behavior, { kind: 'items', id: 'carrierId', type: 'carrierType', place: 'slotNo' })
120
+ editFigurePart(asset, { kind: 'set-data-binding', binding: items })
81
121
  })
82
122
 
83
- test('샘플 값: 무엇을 움직이는지 커널의 평가로, 틀린 값 하나면 통째로 거절한다', () => {
84
- const ok = dataSampleOf(DOG, { mode: 'run', cadence: 2, headAngle: 10, batteryLow: true })
85
- assert.ok('result' in ok)
123
+ test('데이터 형식 트리: 필드를 아래에 더하고, 지우고, 종류를 바꾼다 -- 원본은 그대로', () => {
124
+ const schema = DOG.dataInterface!.schema
125
+ const more = schemaWithField(schema, 'load[]', { name: 'weight', kind: 'number', unit: 'kg' } as FigureDataField)
126
+ assert.equal(fieldAt(more, 'load[].weight')?.kind, 'number')
127
+ assert.equal(fieldAt(schema, 'load[].weight'), undefined, '원본 형식은 바뀌지 않는다')
128
+ assert.equal(fieldAt(schemaWithoutField(more, 'load[].weight'), 'load[].weight'), undefined)
129
+ assert.deepEqual(fieldOfKind({ name: 'heading', label: '방향', kind: 'number', unit: 'deg' }, 'text'), { name: 'heading', label: '방향', kind: 'text' })
130
+ /* 읽는 연결이 있는 필드를 지우면 커널이 거절한다. */
131
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-schema', schema: schemaWithoutField(schema, 'mode') })), /^figure\.text\.data-accessor-invalid-absent .*"accessor":"mode"/)
132
+ /* 목록 밖 필드는 읽는 연결과 함께 이름이 바뀐다. */
133
+ const renamed = editFigurePart(DOG, { kind: 'rename-data-field', accessor: 'mode', to: 'MODE' })
134
+ assert.equal(renamed.dataInterface!.bindings.find(b => b.id === 'mode-locomotion')!.accessor, 'MODE')
135
+ })
136
+
137
+ test('샘플: 보드의 Figure 와 같은 읽기로 -- 무엇을 움직이는지, 틀린 값이 있으면 통째로 거절', () => {
138
+ const ok = sampleOf(DOG, { mode: 'run', cadence: 2, headAngle: 10, batteryLow: true, status: 'RUN', position: { x: 100, z: 50 }, heading: 90, load: [{ id: 'B1', slot: 't1_s1' }] })
139
+ assert.deepEqual(ok.issues, [])
86
140
  assert.deepEqual(JSON.parse(JSON.stringify(ok.result.motion)), { locomotion: { clip: 'run', speed: 2 } })
87
- const bad = dataSampleOf(DOG, { mode: 'run', headAngle: 90 })
88
- assert.ok('issues' in bad)
89
- assert.deepEqual(bad.issues.map(i => `${i.property} ${i.reason}`), ['headAngle out-of-range'])
90
- const mode = DOG.dataInterface!.properties.find(p => p.id === 'mode')!
91
- const low = DOG.dataInterface!.properties.find(p => p.id === 'batteryLow')!
92
- const cadence = DOG.dataInterface!.properties.find(p => p.id === 'cadence')!
93
- assert.deepEqual([sampleValueOf(mode, 'walk'), sampleValueOf(low, 'false'), sampleValueOf(cadence, '0')], ['walk', false, 0])
94
- assert.ok(Number.isNaN(sampleValueOf(cadence, '') as number), '빈 칸은 0 이 아니다')
141
+ assert.deepEqual(ok.result.status, { state: 'RUN' })
142
+ assert.deepEqual(ok.result.items.map(i => i.records), [[{ id: 'B1', place: 't1_s1' }]])
143
+ const bad = sampleOf(DOG, { mode: 'fly', headAngle: 90 })
144
+ assert.deepEqual(bad.issues.map(i => `${i.path} ${i.reason}`), ['mode value-unknown', 'headAngle out-of-range'])
145
+ /* 손으로 주는 값: 연결이 읽는 값 하나, 목록 밖. */
146
+ assert.deepEqual(valueRowsOf(DOG).map(r => r.path), ['mode', 'cadence', 'headAngle', 'batteryLow', 'status', 'position.x', 'position.z', 'heading'])
147
+ const row = (path: string) => valueRowsOf(DOG).find(r => r.path === path)!.node
148
+ assert.deepEqual([typedValueOf(row('mode'), 'walk'), typedValueOf(row('batteryLow'), 'false'), typedValueOf(row('cadence'), '0')], ['walk', false, 0])
149
+ assert.ok(Number.isNaN(typedValueOf(row('cadence'), '') as number), '빈 칸은 0 이 아니다')
150
+ })
151
+
152
+ test('상태 표시 초안: 첫 색 토큰에 -- 커널이 받는다', () => {
153
+ const port = portDraftOf(DOG, 'lamp')
154
+ assert.deepEqual(port, { id: 'lamp', label: 'lamp', tokens: ['palette.body'], parts: [] })
155
+ editFigurePart(DOG, { kind: 'set-status-port', port })
156
+ assert.equal(portDraftOf(DOG, 'state').id, 'state-2')
95
157
  })
96
158
 
97
159
  test('데이터 · 동작 거절은 커널 문장이 아니라 reason 과 값으로 말한다', () => {
98
- assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-data-property', id: 'mode' })), /^figure\.text\.data-property-connected .*mode-locomotion/)
99
- assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-driver', state: 'fl-knee' })), /^figure\.text\.driver-clip-required .*walk/)
100
160
  assert.match(
101
- refusalOf(() => editFigurePart(DOG, { kind: 'set-data-connection', connection: { id: 'head-2', property: 'headAngle', transform: { kind: 'affine', scale: 2, offset: 0 }, target: { kind: 'state', input: 'head', sourceMode: 'observed' } } })),
102
- /^figure\.text\.data-transform-range .*"min":"-90".*"low":"-45"/
161
+ refusalOf(() =>
162
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: { id: 'head-2', accessor: 'headAngle', behavior: { kind: 'state', input: 'head', sourceMode: 'observed' }, rule: 'scale', param: { scale: 2, offset: 0 } } })
163
+ ),
164
+ /^figure\.text\.data-rule-range .*"min":"-90".*"low":"-45"/
165
+ )
166
+ assert.match(
167
+ refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: { id: 'lamp-2', accessor: 'mode', behavior: { kind: 'palette', token: 'palette.battery' }, rule: 'map', param: { default: '#000000' } } })),
168
+ /^figure\.text\.data-behavior-duplicate .*battery-colour/
103
169
  )
170
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-status-port', id: 'state' })), /^figure\.text\.data-port-bound .*body-status/)
171
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-driver', state: 'fl-knee' })), /^figure\.text\.driver-clip-required .*walk/)
104
172
  const lone = editFigurePart(quadrupedRobotFigure().asset, { kind: 'set-driver', state: 'fl-knee', clip: 'walk', duration: 1, keys: [{ seconds: 0, value: 0 }, { seconds: 1, value: 10 }] })
105
173
  assert.match(refusalOf(() => editFigurePart(lone, { kind: 'set-driver', state: 'fl-knee', clip: 'run', duration: 1, keys: [{ seconds: 0, value: 0 }, { seconds: 1, value: 10 }] })), /^figure\.text\.driver-writers /)
106
174
  })
107
175
 
176
+ test('미리보기: 데이터가 정하는 그룹 필드는 그룹 칸이 아니라 데이터 값으로 움직인다 (2026-10-14, 로봇 개)', () => {
177
+ /* The dog's clip and speed are both written by data (mode, cadence): a clip chosen on the group moved nothing. */
178
+ assert.deepEqual(motionWrittenByData(DOG), { locomotion: { clip: true, speed: true } })
179
+ /* Only the speed bound: the clip stays the group's to choose. */
180
+ const speedOnly = { ...DOG, dataInterface: { ...DOG.dataInterface!, bindings: DOG.dataInterface!.bindings.filter(b => b.behavior.kind !== 'motion-clip') } }
181
+ assert.deepEqual(motionWrittenByData(speedOnly), { locomotion: { clip: false, speed: true } })
182
+ /* No data interface: every group field is the group's own. */
183
+ const { dataInterface: _gone, ...bare } = DOG
184
+ assert.deepEqual(motionWrittenByData(bare as FigureAsset), {})
185
+ const preview = read('../client/modeller/figure-preview.ts')
186
+ assert.match(preview, /tellInstances\('figureValues', this\.valuing\)/)
187
+ assert.match(preview, /byData\.clip\s*\? nothing/)
188
+ assert.match(preview, /byData\.speed\s*\? nothing/)
189
+ })
190
+
108
191
  test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () => {
109
192
  const editor = read('../client/modeller/editor.ts')
110
193
  assert.match(editor, /renderTab\('data'/)
111
194
  assert.match(editor, /<figure-data-panel/)
112
195
  assert.match(editor, /\.sample=\$\{this\.dataSample\}/)
196
+ assert.match(editor, /this\.dataSample = e\.detail\.data/)
113
197
  const preview = read('../client/modeller/figure-preview.ts')
114
- /* 라이브 스냅숏은 저장 모델이 아니라 Figure 의 figureData 접근자로 간다. */
115
- assert.match(preview, /\.figureData = value/)
198
+ /* 샘플은 보드의 데이터 소스처럼 Figure 의 data 로 간다 -- 저장 모델에는 쓰지 않는다. */
199
+ assert.match(preview, /component\.set\?\.\('data', /)
200
+ assert.doesNotMatch(preview, /figureData/)
116
201
  assert.match(preview, /tellInstances\('motion'/)
117
202
  const clips = read('../client/modeller/figure-clips.ts')
118
203
  assert.match(clips, /kind: 'remove-driver', state: track\.state, clip: clip\.name/)
119
204
  /* A key's value is shown to three decimals: a slider's 14.399999999999999 is not what a person typed. */
120
205
  assert.match(clips, /value: current === undefined \? '' : shown\(current\)/)
121
206
  assert.match(clips, /\$\{key\.seconds\}s → \$\{shown\(key\.value\)\}/)
122
- const keys = [read('../client/modeller/figure-data-panel.ts'), clips, read('../client/modeller/refusals.ts')].flatMap(src => [...src.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!))
123
- /* Keys made from a value: each value the kernel can give has its words. */
207
+ const keys = [read('../client/modeller/figure-data-panel.ts'), clips, read('../client/modeller/refusals.ts')].flatMap(src =>
208
+ [...src.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!)
209
+ )
210
+ /* Keys made from a value: each value the kernel or the reader can give has its words. */
124
211
  const made = [
125
212
  /* The tab's short name, the one it shows under its icon (renderTab reads figure.label.tab-short-<tab>). */
126
213
  'figure.label.tab-short-data',
127
- ...['number', 'boolean', 'enum'].map(k => `figure.label.data-kind-${k}`),
128
- ...['state', 'palette', 'motion-clip', 'motion-speed'].flatMap(k => [`figure.label.data-target-${k}`, `figure.text.data-target-absent-${k}`]),
214
+ ...['number', 'boolean', 'text', 'object', 'list'].map(k => `figure.label.data-kind-${k}`),
215
+ ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'pose', 'items'].map(k => `figure.label.data-behavior-${k}`),
216
+ ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'items'].map(k => `figure.text.data-behavior-absent-${k}`),
217
+ ...['x', 'y', 'z', 'heading'].map(k => `figure.label.data-axis-${k}`),
218
+ ...['value', 'map', 'range', 'scale'].map(k => `figure.label.data-rule-${k}`),
219
+ ...['id', 'type', 'place', 'stack', 'status'].map(k => `figure.label.data-item-${k}`),
129
220
  ...['observed', 'commanded'].map(k => `figure.label.data-${k}`),
130
- ...['label', 'unit', 'min', 'default', 'values'].map(k => `figure.text.data-property-invalid-${k}`),
131
- ...['kind', 'unit', 'range', 'cases', 'output'].map(k => `figure.text.data-transform-${k}`),
221
+ ...['name', 'label', 'json', 'unit', 'min', 'max', 'default', 'values'].map(k => `figure.text.data-field-invalid-${k}`),
222
+ ...['syntax', 'absent', 'not-a-list', 'not-a-value', 'list-needs-index', 'item-id-required'].map(k => `figure.text.data-accessor-invalid-${k}`),
223
+ ...['kind', 'unit', 'range', 'cases', 'output', 'default'].map(k => `figure.text.data-rule-${k}`),
224
+ ...['label', 'token', 'part', 'group'].map(k => `figure.text.data-port-invalid-${k}`),
132
225
  ...['group-unknown', 'clip-not-in-group', 'speed-out-of-range', 'group-selection-required'].map(k => `figure.text.motion-setting-${k}`),
133
- ...['not-a-record', 'property-unknown', 'type-mismatch', 'out-of-range', 'enum-unknown'].map(k => `figure.text.data-value-${k}`)
226
+ ...['not-a-record', 'not-a-list', 'type-mismatch', 'out-of-range', 'value-unknown', 'json-unreadable', 'item-id-missing'].map(k => `figure.text.data-value-${k}`)
134
227
  ]
135
228
  for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
136
229
  const table = JSON.parse(read(`../translations/${lang}.json`))
@@ -1039,16 +1039,9 @@
1039
1039
  "figure.button.add-clip-to-group": "add a clip",
1040
1040
  "figure.text.motion-groups-explained": "Clips of one group play one at a time. For several clips to move the same control, put them in one group. A change of clip blends from the pose left to the new clip over the transition time.",
1041
1041
  "figure.label.tab-data": "data",
1042
- "figure.label.data-properties": "data properties",
1043
- "figure.text.data-properties-explained": "Values the site data gives this figure. A board maps its data onto these ids, never onto a part or an inner name.",
1044
- "figure.label.data-connections": "connections",
1045
- "figure.text.data-connections-explained": "What a data property moves: a control, a colour, or a motion group's clip and speed. Every value the property can take must land inside what the target takes.",
1046
- "figure.text.data-no-properties": "Add a data property first.",
1047
1042
  "figure.label.data-kind": "kind",
1048
1043
  "figure.label.data-kind-number": "number",
1049
1044
  "figure.label.data-kind-boolean": "true/false",
1050
- "figure.label.data-kind-enum": "choice",
1051
- "figure.label.data-id": "id",
1052
1045
  "figure.label.data-unit": "unit",
1053
1046
  "figure.label.data-min": "min",
1054
1047
  "figure.label.data-max": "max",
@@ -1056,51 +1049,124 @@
1056
1049
  "figure.label.data-values": "values (comma-separated)",
1057
1050
  "figure.label.data-true": "true",
1058
1051
  "figure.label.data-false": "false",
1059
- "figure.button.add-data-property": "add a data property",
1060
- "figure.button.remove-data-property": "remove the data property",
1061
- "figure.label.data-property": "data property",
1062
- "figure.label.data-target": "target",
1063
- "figure.label.data-target-state": "control {name}",
1064
- "figure.label.data-target-palette": "colour {name}",
1065
- "figure.label.data-target-motion-clip": "{name} clip",
1066
- "figure.label.data-target-motion-speed": "{name} speed",
1067
- "figure.button.add-data-connection": "add a connection",
1068
- "figure.button.remove-data-connection": "remove the connection",
1069
1052
  "figure.label.data-source-mode": "applies",
1070
1053
  "figure.label.data-observed": "measured (at once)",
1071
1054
  "figure.label.data-commanded": "commanded (travels at the sweep)",
1072
- "figure.label.data-transform": "transform",
1073
- "figure.label.data-identity": "as it is",
1074
- "figure.label.data-affine": "scale and offset",
1075
1055
  "figure.label.data-scale": "scale",
1076
1056
  "figure.label.data-offset": "offset",
1077
1057
  "figure.label.data-sample": "sample data",
1078
- "figure.text.data-sample-explained": "Try values to see what they move. Not saved. Applied to the preview, the preview's figure runs on them.",
1058
+ "figure.text.data-sample-explained": "Enter one record as JSON to see what it moves. It is read the way a figure on a board reads its data, and not saved.",
1059
+ "figure.text.data-sample-moves-all": "A position in the data moves every figure in the preview to it; on a board, figures given the same value stand in the same place too.",
1079
1060
  "figure.label.data-sample-in-preview": "apply to the preview",
1080
- "figure.text.data-value-not-a-record": "The sample is not a set of values.",
1081
- "figure.text.data-value-property-unknown": "{property} is not a data property of this figure.",
1082
- "figure.text.data-value-type-mismatch": "The value of {property} is of the wrong kind.",
1083
- "figure.text.data-value-out-of-range": "The value of {property} is outside its range.",
1084
- "figure.text.data-value-enum-unknown": "{property} does not take this value.",
1061
+ "figure.label.data-schema": "data shape",
1062
+ "figure.text.data-schema-explained": "The shape of the data a site sends this figure. Name each field as the site's data names it; group fields in objects and lists.",
1063
+ "figure.label.data-bound": "{count} bound",
1064
+ "figure.text.data-no-fields": "Add a field to the data shape first.",
1065
+ "figure.label.data-name": "field name",
1066
+ "figure.label.data-label": "display name",
1067
+ "figure.label.data-json": "also read as JSON text",
1068
+ "figure.label.data-item-kind": "element kind",
1069
+ "figure.label.data-kind-text": "text",
1070
+ "figure.label.data-kind-object": "object",
1071
+ "figure.label.data-kind-list": "list",
1072
+ "figure.button.add-data-field": "add a field",
1073
+ "figure.button.add-data-child": "add a field under {parent}",
1074
+ "figure.button.remove-data-field": "remove the field",
1075
+ "figure.label.data-from-sample": "make from a sample",
1076
+ "figure.text.data-from-sample-explained": "Paste one record of the site's data to guess its fields. Units, ranges and value lists are not guessed; set them after.",
1077
+ "figure.text.data-not-json": "This is not JSON.",
1078
+ "figure.text.data-guessed": "{count} fields found.",
1079
+ "figure.text.data-guessed-replaces": "{count} fields found. Using them replaces the current data shape.",
1080
+ "figure.button.data-use-guessed": "use this shape",
1081
+ "figure.label.data-bindings": "bindings",
1082
+ "figure.text.data-bindings-explained": "One data value moves one thing the figure does: as it is, one result per value, per interval, or scaled. Click a line to edit how.",
1083
+ "figure.label.data-field": "data value",
1084
+ "figure.label.data-behavior": "behaviour",
1085
+ "figure.label.data-behavior-state": "control {name}",
1086
+ "figure.label.data-behavior-palette": "colour {name}",
1087
+ "figure.label.data-behavior-motion-clip": "{name} clip",
1088
+ "figure.label.data-behavior-motion-speed": "{name} speed",
1089
+ "figure.label.data-behavior-motion-paused": "{name} paused",
1090
+ "figure.label.data-behavior-status": "status {name}",
1091
+ "figure.label.data-behavior-pose": "position {name}",
1092
+ "figure.label.data-behavior-items": "items {name}",
1093
+ "figure.label.data-axis-x": "X",
1094
+ "figure.label.data-axis-y": "height (Y)",
1095
+ "figure.label.data-axis-z": "Z",
1096
+ "figure.label.data-axis-heading": "heading",
1097
+ "figure.label.data-rule": "rule",
1098
+ "figure.label.data-rule-value": "as it is",
1099
+ "figure.label.data-rule-map": "per value",
1100
+ "figure.label.data-rule-range": "per interval",
1101
+ "figure.label.data-rule-scale": "scale and offset",
1102
+ "figure.label.data-seconds": "travel time (s)",
1103
+ "figure.label.data-paused": "paused",
1104
+ "figure.label.data-playing": "playing",
1105
+ "figure.label.data-otherwise": "any other value",
1106
+ "figure.label.data-range-from": "from",
1107
+ "figure.label.data-range-to": "below",
1108
+ "figure.button.add-data-range": "add an interval",
1109
+ "figure.button.data-set-result": "set",
1110
+ "figure.button.remove-data-range": "remove the interval",
1111
+ "figure.label.data-item-id": "item id",
1112
+ "figure.label.data-item-type": "item type",
1113
+ "figure.label.data-item-place": "place",
1114
+ "figure.label.data-item-stack": "stack order",
1115
+ "figure.label.data-item-status": "status",
1116
+ "figure.label.data-item-slot": "default place",
1117
+ "figure.text.data-no-behavior": "Nothing of this figure can be moved by this value.",
1118
+ "figure.button.add-data-binding": "add a binding",
1119
+ "figure.button.remove-data-binding": "remove the binding",
1120
+ "figure.label.data-ports": "status display",
1121
+ "figure.text.data-ports-explained": "Where a status code shows. The board's status template picks the colour; here you choose the colour tokens that take it and the parts that blink or glow.",
1122
+ "figure.label.data-port-tokens": "colour tokens",
1123
+ "figure.label.data-port-parts": "parts",
1124
+ "figure.button.add-data-port": "add a status display",
1125
+ "figure.button.remove-data-port": "remove the status display",
1126
+ "figure.label.data-items-count": "{count} items",
1127
+ "figure.text.data-value-not-a-list": "{path}: not a list.",
1128
+ "figure.text.data-value-value-unknown": "{path}: not one of its values.",
1129
+ "figure.text.data-value-json-unreadable": "{path}: the JSON text cannot be read.",
1130
+ "figure.text.data-value-item-id-missing": "{path}: the item has no id.",
1131
+ "figure.text.data-field-invalid-name": "A field name starts with a letter or _, then letters, digits, _ and -.",
1132
+ "figure.text.data-field-invalid-label": "Write a display name, or leave it empty.",
1133
+ "figure.text.data-field-invalid-json": "Only an object or a list may come as JSON text.",
1134
+ "figure.text.data-field-invalid-unit": "The unit is not one the figure knows.",
1135
+ "figure.text.data-field-invalid-min": "Write the range from the smaller number to the larger.",
1136
+ "figure.text.data-field-invalid-max": "Write the max as a number.",
1137
+ "figure.text.data-field-invalid-default": "The default must lie in the range or the value list.",
1138
+ "figure.text.data-field-invalid-values": "The value list needs one or more values, each once.",
1139
+ "figure.text.data-field-duplicate": "There is already a field {name}.",
1140
+ "figure.text.data-accessor-invalid-syntax": "{accessor} is not a data path.",
1141
+ "figure.text.data-accessor-invalid-absent": "The data shape has no field {accessor}.",
1142
+ "figure.text.data-accessor-invalid-not-a-list": "{accessor} is not a list.",
1143
+ "figure.text.data-accessor-invalid-not-a-value": "{accessor} is not one value; choose a field under it.",
1144
+ "figure.text.data-accessor-invalid-list-needs-index": "{accessor} is a list; choose a value of one element.",
1145
+ "figure.text.data-accessor-invalid-item-id-required": "A list of items needs an id field in each element.",
1146
+ "figure.text.data-behavior-absent-state": "This figure has no control {id}.",
1147
+ "figure.text.data-behavior-absent-palette": "This figure has no colour {id}.",
1148
+ "figure.text.data-behavior-absent-motion-clip": "There is no motion group {id}.",
1149
+ "figure.text.data-behavior-absent-motion-speed": "There is no motion group {id}.",
1150
+ "figure.text.data-behavior-absent-motion-paused": "There is no motion group {id}.",
1151
+ "figure.text.data-behavior-absent-status": "There is no status display {id}.",
1152
+ "figure.text.data-behavior-absent-items": "There is no place {id}.",
1153
+ "figure.text.data-behavior-duplicate": "The bindings {a} and {b} both move {id}; one binding per behaviour.",
1154
+ "figure.text.data-rule-kind": "This rule does not fit this value and behaviour.",
1155
+ "figure.text.data-rule-unit": "The units differ ({from} → {to}); convert with scale and offset.",
1156
+ "figure.text.data-rule-range": "It gives {min} to {max}; the behaviour takes {low} to {high}.",
1157
+ "figure.text.data-rule-cases": "{value} is not one of this value's values.",
1158
+ "figure.text.data-rule-output": "{value} is not something the behaviour takes.",
1159
+ "figure.text.data-rule-default": "Set a result for any other value, so every value has one.",
1160
+ "figure.text.data-port-invalid-label": "Write a name for the status display.",
1161
+ "figure.text.data-port-invalid-token": "Choose one or more of this figure's colour tokens or parts.",
1162
+ "figure.text.data-port-invalid-part": "{id} is not a part of this figure.",
1163
+ "figure.text.data-port-invalid-group": "There is no motion group {id}.",
1164
+ "figure.text.data-port-bound": "First remove the bindings that read the status display {port}: {bindings}.",
1165
+ "figure.text.data-value-not-a-record": "{path}: not an object.",
1166
+ "figure.text.data-value-type-mismatch": "{path}: the value is of the wrong kind.",
1167
+ "figure.text.data-value-out-of-range": "{path}: outside its range.",
1085
1168
  "figure.text.data-id-invalid": "The id {id} cannot be used: a letter first, then letters, digits, - and _.",
1086
1169
  "figure.text.data-id-duplicate": "The id {id} is already used.",
1087
- "figure.text.data-property-invalid-label": "Give {property} a name.",
1088
- "figure.text.data-property-invalid-unit": "The unit of {property} is not a unit.",
1089
- "figure.text.data-property-invalid-min": "The range of {property} runs from a smaller number to a larger one.",
1090
- "figure.text.data-property-invalid-default": "The default of {property} must be within its range or its values.",
1091
- "figure.text.data-property-invalid-values": "The values of {property} are one or more, each once.",
1092
- "figure.text.data-property-absent": "{property} is not a data property of this figure.",
1093
- "figure.text.data-target-absent-state": "There is no control {id}. Remove the connection that writes it first.",
1094
- "figure.text.data-target-absent-palette": "There is no colour {id}.",
1095
- "figure.text.data-target-absent-motion-clip": "There is no motion group {id}.",
1096
- "figure.text.data-target-absent-motion-speed": "There is no motion group {id}.",
1097
- "figure.text.data-target-duplicate": "Connections {a} and {b} both write {id}; one connection per target.",
1098
- "figure.text.data-transform-kind": "This transform does not fit this property and target: a number goes as it is or scaled, a choice or true/false case by case.",
1099
- "figure.text.data-transform-unit": "The units differ ({from} → {to}). Say how one becomes the other with a scale and offset.",
1100
- "figure.text.data-transform-range": "It gives {min} to {max}; the target takes {low} to {high}.",
1101
- "figure.text.data-transform-cases": "Give each value once: {value} is missing, given twice, or not a value of the property.",
1102
- "figure.text.data-transform-output": "{value} is not a value the target takes.",
1103
- "figure.text.data-property-connected": "Remove the connections that read {property} first: {connections}.",
1104
1170
  "figure.text.motion-clip-absent": "{clip} is not a clip {group} can hold.",
1105
1171
  "figure.text.motion-clip-shared": "{clip} is already in {group}; a clip is in one group.",
1106
1172
  "figure.text.driver-writers": "Clips {clips} all move {state}. Clips that move one control go in one motion group.",