@things-factory/figure-ui 10.1.223 → 10.1.225

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 (80) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor-styles.ts +3 -2
  5. package/client/modeller/editor.ts +17 -8
  6. package/client/modeller/figure-capability-panel.ts +151 -1
  7. package/client/modeller/figure-clips.ts +1 -1
  8. package/client/modeller/figure-data-panel.ts +509 -199
  9. package/client/modeller/figure-links.ts +130 -12
  10. package/client/modeller/figure-preview.ts +65 -8
  11. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  12. package/client/modeller/links.ts +58 -0
  13. package/client/modeller/product-session.ts +3 -2
  14. package/client/modeller/refusals.ts +12 -4
  15. package/client/modeller/slot-layout-host.ts +76 -5
  16. package/client/pages/figure-modeller-page.ts +2 -2
  17. package/dist-client/modeller/canvas.js +2 -2
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  20. package/dist-client/modeller/consumer-capabilities.js +60 -0
  21. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  22. package/dist-client/modeller/data-interface.d.ts +83 -17
  23. package/dist-client/modeller/data-interface.js +194 -82
  24. package/dist-client/modeller/data-interface.js.map +1 -1
  25. package/dist-client/modeller/editor-styles.js +3 -2
  26. package/dist-client/modeller/editor-styles.js.map +1 -1
  27. package/dist-client/modeller/editor.d.ts +5 -2
  28. package/dist-client/modeller/editor.js +21 -8
  29. package/dist-client/modeller/editor.js.map +1 -1
  30. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  31. package/dist-client/modeller/figure-capability-panel.js +129 -1
  32. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-clips.js +1 -1
  34. package/dist-client/modeller/figure-clips.js.map +1 -1
  35. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  36. package/dist-client/modeller/figure-data-panel.js +476 -191
  37. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  38. package/dist-client/modeller/figure-links.d.ts +11 -0
  39. package/dist-client/modeller/figure-links.js +137 -16
  40. package/dist-client/modeller/figure-links.js.map +1 -1
  41. package/dist-client/modeller/figure-preview.d.ts +4 -0
  42. package/dist-client/modeller/figure-preview.js +65 -7
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  45. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  46. package/dist-client/modeller/links.d.ts +22 -0
  47. package/dist-client/modeller/links.js +48 -0
  48. package/dist-client/modeller/links.js.map +1 -1
  49. package/dist-client/modeller/product-session.d.ts +4 -2
  50. package/dist-client/modeller/product-session.js +3 -2
  51. package/dist-client/modeller/product-session.js.map +1 -1
  52. package/dist-client/modeller/refusals.js +14 -6
  53. package/dist-client/modeller/refusals.js.map +1 -1
  54. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  55. package/dist-client/modeller/slot-layout-host.js +68 -6
  56. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.js +2 -2
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +8 -8
  61. package/test/ai-proposal.test.ts +2 -2
  62. package/test/browser/data-table-page.js +9 -0
  63. package/test/browser/links.check.mjs +25 -3
  64. package/test/browser/product-page.check.mjs +25 -0
  65. package/test/browser/side-panel-sweep.check.mjs +182 -0
  66. package/test/consumer-capabilities.test.ts +116 -0
  67. package/test/data-motion.test.ts +94 -27
  68. package/test/figure-assistant-surface.test.ts +1 -1
  69. package/test/first-save.test.ts +2 -2
  70. package/test/links.test.ts +15 -0
  71. package/test/product-session.test.ts +5 -5
  72. package/test/proposal-record.test.ts +3 -3
  73. package/test/revert-keeping.test.ts +1 -1
  74. package/test/slot-layout-host.test.ts +63 -5
  75. package/translations/en.json +82 -23
  76. package/translations/es.json +82 -23
  77. package/translations/ja.json +82 -23
  78. package/translations/ko.json +82 -23
  79. package/translations/ms.json +82 -23
  80. package/translations/zh.json +82 -23
@@ -22,9 +22,16 @@ import {
22
22
  fieldOfKind,
23
23
  fitsField,
24
24
  freeIdOf,
25
+ legendRoleOf,
26
+ methodsOf,
25
27
  motionWrittenByData,
26
- portDraftOf,
27
28
  rotationStepsOf,
29
+ schemaFollowingCodes,
30
+ tableColumnsOf,
31
+ tableDraftOf,
32
+ tableWithKeys,
33
+ tableWithLook,
34
+ tableWithTargets,
28
35
  sampleOf,
29
36
  schemaWithField,
30
37
  schemaWithoutField,
@@ -78,14 +85,24 @@ const withField = (asset: FigureAsset, field: FigureDataField) => editFigurePart
78
85
  const choice = (asset: FigureAsset, key: string) => behaviorsOf(asset).find(c => c.key === key)!
79
86
  const draft = (asset: FigureAsset, path: string, key: string) => bindingDraftOf(asset, accessorOfRow(path), fieldAt(asset.dataInterface!.schema, path)!, choice(asset, key))
80
87
 
81
- test('작동 목록: 이 Figure 가 할 수 있는 것 전부, 값의 종류마다 맞는 것만', () => {
88
+ test('작동 목록과 방법: 값 그대로 · 배율이 움직일 수 있는 것, 칸 종류가 허락하는 방법만 (ADR-0166 2)', () => {
82
89
  const kinds = [...new Set(behaviorsOf(DOG).map(c => c.kind))]
83
- assert.deepEqual(kinds, ['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'pose', 'items'])
90
+ assert.deepEqual(kinds, ['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'pose', 'items'])
84
91
  const fits = (field: FigureDataField) => [...new Set(behaviorsOf(DOG).filter(c => fitsField(field, c)).map(c => c.kind))]
85
- assert.deepEqual(fits({ name: 'n', kind: 'number' }), ['state', 'palette', 'motion-clip', 'motion-speed', 'status', 'pose', 'items'])
86
- assert.deepEqual(fits({ name: 'b', kind: 'boolean' }), ['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused'])
92
+ assert.deepEqual(fits({ name: 'n', kind: 'number' }), ['state', 'motion-speed', 'pose', 'items'])
93
+ assert.deepEqual(fits({ name: 'b', kind: 'boolean' }), ['motion-paused'])
94
+ assert.deepEqual(fits({ name: 'g', kind: 'text', values: ['idle', 'walk'] }), ['motion-clip', 'items'], '클립 이름만 든 닫힌 글자는 클립 그대로')
95
+ assert.deepEqual(fits({ name: 't', kind: 'text', values: ['RUN', 'DOWN'] }), ['items'])
87
96
  assert.deepEqual(fits({ name: 'o', kind: 'object', fields: [] }), [])
88
97
  assert.deepEqual(fits({ name: 'l', kind: 'list', item: { kind: 'object', fields: [] } }), ['items'])
98
+ const choices = behaviorsOf(DOG)
99
+ assert.deepEqual(methodsOf({ kind: 'number' }, choices), ['value', 'scale', 'ranges'])
100
+ assert.deepEqual(methodsOf({ kind: 'text' }, choices), ['value', 'code'])
101
+ assert.deepEqual(methodsOf({ kind: 'boolean' }, choices), ['value', 'code'])
102
+ assert.deepEqual(methodsOf({ kind: 'list', item: { kind: 'text' } }, choices), ['value'])
103
+ assert.deepEqual(methodsOf(null, choices), ['code', 'ranges'], '새 필드는 표가 만든다')
104
+ const plain = behaviorsOf({ ...DOG, motionGroups: undefined, drivers: undefined, document: { ...DOG.document, capabilities: {} } } as FigureAsset)
105
+ assert.deepEqual(methodsOf({ kind: 'boolean' }, plain), ['code'], '움직일 것이 없으면 코드 표만')
89
106
  })
90
107
 
91
108
  test('첫 연결 초안: 맞으면 값 그대로, 범위·단위만 다르면 배율, 닫힌 값은 값마다 -- 커널이 받는다', () => {
@@ -101,12 +118,6 @@ test('첫 연결 초안: 맞으면 값 그대로, 범위·단위만 다르면
101
118
  asset = withField(asset, { name: 'east', kind: 'number', unit: 'm' })
102
119
  const east = draft({ ...asset, dataInterface: { ...asset.dataInterface!, bindings: asset.dataInterface!.bindings.filter(b => b.id !== 'at-x') } }, 'east', 'pose:x')
103
120
  assert.deepEqual([east.rule, east.param], ['scale', { scale: 1000, offset: 0 }])
104
- /* 닫힌 글자를 색에: 값마다 지금 색으로 시작한다. */
105
- const body = draft(asset, 'mode', 'palette:palette.body')
106
- assert.deepEqual([body.rule, body.param], ['map', Object.fromEntries(['idle', 'walk', 'run'].map(v => [v, DOG.palette['palette.body']]))])
107
- editFigurePart(asset, { kind: 'set-data-binding', binding: body })
108
- /* 열린 숫자를 색에: 구간마다, 그 밖은 지금 색. */
109
- assert.deepEqual(draft(asset, 'lean', 'palette:palette.body').param, { default: DOG.palette['palette.body'] })
110
121
  assert.equal(freeIdOf(['mode', 'mode-2'], 'mode'), 'mode-3')
111
122
  assert.equal(freeIdOf([], '배터리'), 'binding', '영문 id 가 남지 않으면 binding')
112
123
  })
@@ -137,10 +148,16 @@ test('데이터 형식 트리: 필드를 아래에 더하고, 지우고, 종류
137
148
  })
138
149
 
139
150
  test('샘플: 보드의 Figure 와 같은 읽기로 -- 무엇을 움직이는지, 틀린 값이 있으면 통째로 거절', () => {
140
- 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' }] })
151
+ const ok = sampleOf(DOG, { mode: 'run', cadence: 2, headAngle: 10, batteryLow: true, status: 'ALARM', position: { x: 100, z: 50 }, heading: 90, load: [{ id: 'B1', slot: 't1_s1' }] })
141
152
  assert.deepEqual(ok.issues, [])
142
153
  assert.deepEqual(JSON.parse(JSON.stringify(ok.result.motion)), { locomotion: { clip: 'run', speed: 2 } })
143
- assert.deepEqual(ok.result.status, { state: 'RUN' })
154
+ assert.deepEqual(ok.result.parts.body, { color: '#e24b4a', effect: { kind: 'blink', hz: 2 } })
155
+ assert.deepEqual(ok.uses['body-status'], { value: 'ALARM', key: 'ALARM', source: 'figure' })
156
+ /* 견본 범례는 그 역할의 표를 통째로 대신한다: 모르는 코드는 맞는 줄 없음으로 말한다. */
157
+ const legend = sampleOf(DOG, { status: 'ALARM' }, { 'robot-condition': { RUN: '#22c55e' } })
158
+ assert.equal(legend.result.parts.body, undefined)
159
+ assert.deepEqual(legend.notes.map(n => n.reason), ['code-unmatched'])
160
+ assert.deepEqual(sampleOf(DOG, { status: 'RUN' }, { 'robot-condition': { RUN: '#22c55e' } }).uses['body-status'], { value: 'RUN', key: 'RUN', source: 'domain', legend: 'robot-condition' })
144
161
  assert.deepEqual(ok.result.items.map(i => i.records), [[{ id: 'B1', place: 't1_s1' }]])
145
162
  const bad = sampleOf(DOG, { mode: 'fly', headAngle: 90 })
146
163
  assert.deepEqual(bad.issues.map(i => `${i.path} ${i.reason}`), ['mode value-unknown', 'headAngle out-of-range'])
@@ -151,11 +168,42 @@ test('샘플: 보드의 Figure 와 같은 읽기로 -- 무엇을 움직이는지
151
168
  assert.ok(Number.isNaN(typedValueOf(row('cadence'), '') as number), '빈 칸은 0 이 아니다')
152
169
  })
153
170
 
154
- test('상태 표시 초안: 첫 색 토큰에 -- 커널이 받는다', () => {
155
- const port = portDraftOf(DOG, 'lamp')
156
- assert.deepEqual(port, { id: 'lamp', label: 'lamp', tokens: ['palette.body'], parts: [] })
157
- editFigurePart(DOG, { kind: 'set-status-port', port })
158
- assert.equal(portDraftOf(DOG, 'state').id, 'state-2')
171
+ test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간 -- 새 필드는 표가 만든다 (ADR-0166 10)', () => {
172
+ const mode = tableDraftOf(DOG, { path: 'mode', node: fieldAt(DOG.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'] })
173
+ assert.deepEqual([mode.binding.id, mode.binding.accessor, mode.binding.table, mode.schema], ['mode', 'mode', { idle: {}, walk: {}, run: {} }, undefined])
174
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: mode.binding })
175
+ const open = tableDraftOf(DOG, { path: 'status', node: fieldAt(DOG.dataInterface!.schema, 'status')! }, 'code', { parts: ['head'] })
176
+ assert.deepEqual(open.binding.table, {})
177
+ const pct = tableDraftOf(DOG, { path: 'cadence', node: fieldAt(DOG.dataInterface!.schema, 'cadence')! }, 'ranges', { parts: ['body'] })
178
+ assert.deepEqual(pct.binding.table, { '0~': {} })
179
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: pct.binding })
180
+ /* 새 이름이면 필드를 만든다: 코드 표는 값 목록이 표의 키인 글자, 구간 표는 숫자. 한 걸음에 둘 다. */
181
+ const made = tableDraftOf(DOG, { name: 'EMPTYTYPE' }, 'code', { parts: ['body'] })
182
+ assert.deepEqual(made.schema!.at(-1), { name: 'EMPTYTYPE', kind: 'text', values: ['CODE'] })
183
+ assert.deepEqual(made.binding.table, { CODE: {} })
184
+ const asset = editFigurePart(DOG, { kind: 'set-data-binding', binding: made.binding, schema: made.schema })
185
+ assert.deepEqual(tableDraftOf(DOG, { name: 'RATE' }, 'ranges', { parts: ['body'] }).schema!.at(-1), { name: 'RATE', kind: 'number' })
186
+ /* 닫힌 필드의 값 목록은 표의 키를 따라간다: 코드를 더하면 필드에도. */
187
+ const b = asset.dataInterface!.bindings.find(x => x.id === 'emptytype')! as never
188
+ const grown = tableWithKeys(b, [['FULL', { color: '#3b6d11' }], ['EMPTY', {}]])
189
+ const schema = schemaFollowingCodes(asset, grown, ['FULL', 'EMPTY'])
190
+ assert.deepEqual(fieldAt(schema!, 'EMPTYTYPE'), { name: 'EMPTYTYPE', kind: 'text', values: ['FULL', 'EMPTY'] })
191
+ editFigurePart(asset, { kind: 'set-data-binding', binding: grown, schema })
192
+ assert.equal(schemaFollowingCodes(DOG, open.binding, ['RUN']), undefined, '열린 필드는 그대로')
193
+ assert.equal(legendRoleOf('Body_Status'), 'body-status')
194
+ })
195
+
196
+ test('표의 대상과 열: 부품은 색·효과, 조작값은 값 하나씩, 동작 그룹은 클립·배속·멈춤 -- 대상을 빼면 그 열도 빠진다', () => {
197
+ const free = editFigurePart(DOG, { kind: 'remove-data-binding', id: 'mode-locomotion' })
198
+ const draftTable = tableDraftOf(free, { path: 'mode', node: fieldAt(free.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'], inputs: ['fl-knee'], group: 'locomotion' }).binding
199
+ assert.deepEqual(tableColumnsOf(free, draftTable).map(c => c.kind), ['color', 'effect', 'value', 'clip', 'speed', 'paused'])
200
+ const looked = tableWithLook(draftTable, 'run', { color: '#e24b4a', effect: { kind: 'blink' }, values: { 'fl-knee': 30 }, clip: 'run' })
201
+ editFigurePart(free, { kind: 'set-data-binding', binding: looked })
202
+ const fewer = tableWithTargets(looked, { inputs: ['fl-knee'] })
203
+ assert.deepEqual([fewer.targets, fewer.table.run], [{ inputs: ['fl-knee'] }, { values: { 'fl-knee': 30 } }])
204
+ editFigurePart(free, { kind: 'set-data-binding', binding: fewer })
205
+ /* 대상 밖의 열은 줄에 들어가지 않는다. */
206
+ assert.deepEqual(tableWithLook(fewer, 'walk', { color: '#000000', values: { 'fl-knee': 10, head: 5 } }).table.walk, { values: { 'fl-knee': 10 } })
159
207
  })
160
208
 
161
209
  test('「+ 회전」: 끝없이 도는 관절에 쌓이는 클립·그룹·연결을 한 번에 -- 최대 rpm 이 배속 3', () => {
@@ -179,6 +227,11 @@ test('「+ 회전」: 끝없이 도는 관절에 쌓이는 클립·그룹·연
179
227
  assert.deepEqual(spun.motionGroups, [{ id: 'j6-tool-roll-spin', label: '툴 회전', clips: ['j6-tool-roll-spin', 'j6-tool-roll-spin-reverse'], defaultClip: 'j6-tool-roll-spin', transitionSeconds: 0 }])
180
228
  /* 범위가 없던 속도 필드는 0..최대 rpm 으로. */
181
229
  assert.deepEqual(spun.dataInterface!.schema.find(f => f.name === 'rpm'), { name: 'rpm', kind: 'number', min: 0, max: 120 })
230
+ /* 켜짐·방향은 표: 켜짐은 멈춤을, 방향은 클립을 정한다. */
231
+ assert.deepEqual(
232
+ spun.dataInterface!.bindings.map(b => [b.id, b.rule]),
233
+ [['j6-tool-roll-spin-on', 'table'], ['j6-tool-roll-spin-speed', 'scale'], ['j6-tool-roll-spin-direction', 'table']]
234
+ )
182
235
  /* 120 rpm 이 배속 3, 60 rpm 이 1.5; REV 는 역회전, 꺼짐은 멈춤, 목록 밖 HOLD 는 정회전. */
183
236
  const at = (data: unknown) => JSON.parse(JSON.stringify(sampleOf(spun, data).result.motion))
184
237
  assert.deepEqual(at({ running: true, rpm: 120, dir: 'FWD' }), { 'j6-tool-roll-spin': { clip: 'j6-tool-roll-spin', speed: 3, paused: false } })
@@ -204,10 +257,17 @@ test('데이터 · 동작 거절은 커널 문장이 아니라 reason 과 값으
204
257
  /^figure\.text\.data-rule-range .*"min":"-90".*"low":"-45"/
205
258
  )
206
259
  assert.match(
207
- refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: { id: 'lamp-2', accessor: 'mode', behavior: { kind: 'palette', token: 'palette.battery' }, rule: 'map', param: { default: '#000000' } } })),
208
- /^figure\.text\.data-behavior-duplicate .*battery-colour/
260
+ refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: { id: 'head-2', accessor: 'mode', rule: 'table', lookup: 'code', targets: { inputs: ['head'] }, table: { default: {} } } })),
261
+ /^figure\.text\.data-behavior-duplicate .*head-angle/
209
262
  )
210
- assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-status-port', id: 'state' })), /^figure\.text\.data-port-bound .*body-status/)
263
+ const table = (t: object) => ({ id: 'look', accessor: 'mode', rule: 'table' as const, lookup: 'code' as const, targets: { parts: ['body'] }, table: { default: {} }, ...t })
264
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: table({ targets: {} }) })), /^figure\.text\.data-table-target-none /)
265
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: table({ targets: { parts: ['tail'] } }) })), /^figure\.text\.data-table-target-part .*"id":"tail"/)
266
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: table({ table: { run: { color: 'red' }, default: {} } }) })), /^figure\.text\.data-table-entry-color .*"key":"run"/)
267
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: table({ table: { default: { clip: 'run' } } }) })), /^figure\.text\.data-table-entry-column .*"key":"figure\.label\.data-otherwise.*"id":"clip"/)
268
+ assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'set-data-binding', binding: table({ legend: { role: 'Body' } }) })), /^figure\.text\.data-legend-invalid .*"role":"Body"/)
269
+ const rate = editFigurePart(DOG, { kind: 'set-data-schema', schema: [...DOG.dataInterface!.schema, { name: 'RATE', kind: 'number' }] })
270
+ assert.match(refusalOf(() => editFigurePart(rate, { kind: 'set-data-binding', binding: table({ accessor: 'RATE', lookup: 'ranges', table: { '~30': {}, '20~': {} } }) })), /^figure\.text\.data-rule-overlap .*"value":"20~"/)
211
271
  assert.match(refusalOf(() => editFigurePart(DOG, { kind: 'remove-driver', state: 'fl-knee' })), /^figure\.text\.driver-clip-required .*walk/)
212
272
  const lone = editFigurePart(quadrupedRobotFigure().asset, { kind: 'set-driver', state: 'fl-knee', clip: 'walk', duration: 1, keys: [{ seconds: 0, value: 0 }, { seconds: 1, value: 10 }] })
213
273
  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 /)
@@ -217,7 +277,7 @@ test('미리보기: 데이터가 정하는 그룹 필드는 그룹 칸이 아니
217
277
  /* The dog's clip and speed are both written by data (mode, cadence): a clip chosen on the group moved nothing. */
218
278
  assert.deepEqual(motionWrittenByData(DOG), { locomotion: { clip: true, speed: true } })
219
279
  /* Only the speed bound: the clip stays the group's to choose. */
220
- const speedOnly = { ...DOG, dataInterface: { ...DOG.dataInterface!, bindings: DOG.dataInterface!.bindings.filter(b => b.behavior.kind !== 'motion-clip') } }
280
+ const speedOnly = { ...DOG, dataInterface: { ...DOG.dataInterface!, bindings: DOG.dataInterface!.bindings.filter(b => b.rule === 'table' || b.behavior.kind !== 'motion-clip') } }
221
281
  assert.deepEqual(motionWrittenByData(speedOnly), { locomotion: { clip: false, speed: true } })
222
282
  /* No data interface: every group field is the group's own. */
223
283
  const { dataInterface: _gone, ...bare } = DOG
@@ -240,6 +300,9 @@ test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () =>
240
300
  /* 샘플은 보드의 데이터 소스처럼 Figure 의 data 로 간다 -- 저장 모델에는 쓰지 않는다. */
241
301
  assert.match(preview, /component\.set\?\.\('data', /)
242
302
  assert.doesNotMatch(preview, /figureData/)
303
+ /* A sample legend is the scene's, on its root container where a board's legends are -- not on the model's root. */
304
+ assert.match(preview, /container\.style = \{ \.\.\.this\.legends \}/)
305
+ assert.match(preview, /style: \{ \.\.\.this\.legends \}/)
243
306
  assert.match(preview, /tellInstances\('motion'/)
244
307
  const clips = read('../client/modeller/figure-clips.ts')
245
308
  assert.match(clips, /kind: 'remove-driver', state: track\.state, clip: clip\.name/)
@@ -254,16 +317,20 @@ test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () =>
254
317
  /* The tab's short name, the one it shows under its icon (renderTab reads figure.label.tab-short-<tab>). */
255
318
  'figure.label.tab-short-data',
256
319
  ...['number', 'boolean', 'text', 'object', 'list'].map(k => `figure.label.data-kind-${k}`),
257
- ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'pose', 'items'].map(k => `figure.label.data-behavior-${k}`),
258
- ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'status', 'items'].map(k => `figure.text.data-behavior-absent-${k}`),
320
+ ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'pose', 'items'].map(k => `figure.label.data-behavior-${k}`),
321
+ ...['state', 'palette', 'motion-clip', 'motion-speed', 'motion-paused', 'items'].map(k => `figure.text.data-behavior-absent-${k}`),
259
322
  ...['x', 'y', 'z', 'heading'].map(k => `figure.label.data-axis-${k}`),
260
- ...['value', 'map', 'range', 'scale'].map(k => `figure.label.data-rule-${k}`),
323
+ ...['value', 'scale', 'code', 'ranges'].map(k => `figure.label.data-method-${k}`),
324
+ ...['color', 'effect', 'values', 'clip', 'speed', 'paused'].map(k => `figure.label.data-column-${k}`),
325
+ ...['none', 'blink', 'glow'].map(k => `figure.label.data-effect-${k}`),
326
+ ...['none', 'part', 'input', 'group'].map(k => `figure.text.data-table-target-${k}`),
327
+ ...['color', 'effect', 'values', 'clip', 'speed', 'paused', 'column'].map(k => `figure.text.data-table-entry-${k}`),
328
+ ...['legend-absent', 'resolver-failed', 'not-a-table', 'not-an-interval', 'code-unmatched', 'not-a-look', 'not-taken'].map(k => `figure.text.data-note-${k}`),
261
329
  ...['id', 'type', 'place', 'stack', 'status'].map(k => `figure.label.data-item-${k}`),
262
330
  ...['observed', 'commanded'].map(k => `figure.label.data-${k}`),
263
331
  ...['name', 'label', 'json', 'unit', 'min', 'max', 'default', 'values'].map(k => `figure.text.data-field-invalid-${k}`),
264
332
  ...['syntax', 'absent', 'not-a-list', 'not-a-value', 'list-needs-index', 'item-id-required'].map(k => `figure.text.data-accessor-invalid-${k}`),
265
- ...['kind', 'unit', 'range', 'cases', 'output', 'default'].map(k => `figure.text.data-rule-${k}`),
266
- ...['label', 'token', 'part', 'group'].map(k => `figure.text.data-port-invalid-${k}`),
333
+ ...['kind', 'unit', 'range', 'cases', 'output', 'default', 'overlap'].map(k => `figure.text.data-rule-${k}`),
267
334
  ...['group-unknown', 'clip-not-in-group', 'speed-out-of-range', 'group-selection-required'].map(k => `figure.text.motion-setting-${k}`),
268
335
  ...['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}`)
269
336
  ]
@@ -95,7 +95,7 @@ test('a new figure keeps its creation id through a new figure\'s proposal and th
95
95
  return { id: q.id ?? q.creationId!, source: q.source }
96
96
  }
97
97
  })
98
- session.startNew()
98
+ session.startNew({ name: 'New' })
99
99
  const creationId = session.read().creationId
100
100
  assert.match(creationId ?? '', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
101
101
  const create: FigureApplyDetail = { key: K1, steps: lid, create: true }
@@ -47,7 +47,7 @@ function recording() {
47
47
  test('타입은 새 피규어를 만드는 저장에만 실린다 — 이미 있는 피규어의 저장은 타입을 보내지 않는다', async () => {
48
48
  const { store, requests } = recording()
49
49
  const session = createFigureProductSession(store)
50
- session.startNew()
50
+ session.startNew({ name: 'New' })
51
51
  await session.save({ type: 'agv-lifter' })
52
52
  assert.equal(requests[0]!.type, 'agv-lifter')
53
53
  assert.equal(requests[0]!.id, undefined, '만드는 저장')
@@ -61,7 +61,7 @@ test('타입은 새 피규어를 만드는 저장에만 실린다 — 이미 있
61
61
  test('타입을 주지 않으면 싣지 않는다 — 서버가 임시 이름을 붙인다', async () => {
62
62
  const { store, requests } = recording()
63
63
  const session = createFigureProductSession(store)
64
- session.startNew()
64
+ session.startNew({ name: 'New' })
65
65
  await session.save()
66
66
  assert.equal('type' in requests[0]!, false)
67
67
  })
@@ -742,3 +742,18 @@ test('어깨는 축 둘 — 축 추가는 쓰지 않은 축으로 시작하고,
742
742
  )
743
743
  assert.notDeepEqual(turned({ 'arm-turn-2': 30 }), turned({}), '남은 축은 같은 조작값으로 움직인다')
744
744
  })
745
+
746
+ test('상태에 따라 크기 — 지름으로 적은 두 끝이 반지름 명령이 되고, 읽어서 고정으로 되돌린다', async () => {
747
+ const { stateSizeCommandOf, stateSizeFixedOf, stateSizeIdFor, freshStateSize } = await import('../client/modeller/links.ts')
748
+ let asset = addFigurePrimitive(createFigureAsset('Roll'), { id: 'wound', kind: 'cylinder', dimensions: { radius: 450, length: 270 }, position: { x: 0, y: 500, z: 0 } })
749
+ assert.equal(stateSizeIdFor(asset, 'wound'), 'fill')
750
+ const form = { ...freshStateSize(), axis: 'x' as const, atMin: 200, atMax: 900 }
751
+ const command = stateSizeCommandOf(asset, 'wound', form, 2, 'fill')
752
+ assert.deepEqual(command, { kind: 'size-by-state', part: 'wound', axis: 'x', state: { id: 'fill', unit: 'percent', min: 0, max: 100 }, at: { min: 100, max: 450 } }, '지름 200 · 900 은 반지름 100 · 450')
753
+ assert.equal(stateSizeCommandOf(asset, 'wound', { ...form, atMin: NaN }, 2, 'fill'), undefined, '한 끝이 비면 보내지 않는다')
754
+ assert.equal(stateSizeCommandOf(asset, 'wound', { ...form, min: 100, max: 0 }, 2, 'fill'), undefined, '범위가 거꾸로면 보내지 않는다')
755
+ asset = applyFigureAuthoring(asset, command!)
756
+ assert.deepEqual(followOf(asset, 'wound', 'x'), { of: 'state', input: 'fill', at: { min: 100, max: 450 } })
757
+ assert.deepEqual(stateSizeFixedOf(asset, 'wound', 'x'), { kind: 'unlink-dimension', part: 'wound', axis: 'x', mm: 450 }, '쉬는 값(100 %)의 반지름으로 고정')
758
+ assert.equal(stateSizeFixedOf(asset, 'wound', 'y'), undefined)
759
+ })
@@ -28,7 +28,7 @@ test('existing model -> new clears geometry, ID, history and derived state', asy
28
28
  const s = createFigureProductSession(store)
29
29
  await s.open('A')
30
30
  assert.equal(s.read().geometry.length, 1)
31
- const v = s.startNew()
31
+ const v = s.startNew({ name: 'New' })
32
32
  assert.equal(v.id, undefined)
33
33
  assert.equal(v.geometry.length, 0)
34
34
  assert.equal(v.canUndo, false)
@@ -46,7 +46,7 @@ test('late load cannot overwrite another model or a new figure', async () => {
46
46
  const p = deferred<FigureProductRecord>(),
47
47
  t = createFigureProductSession({ ...store, load: () => p.promise })
48
48
  const loading = t.open('A')
49
- t.startNew()
49
+ t.startNew({ name: 'New' })
50
50
  p.resolve({ id: 'A', source: source() })
51
51
  await loading
52
52
  assert.equal(t.read().id, undefined)
@@ -95,10 +95,10 @@ test('save carries exact baseline and acknowledges only matching response', asyn
95
95
  test('pending save locks edits/navigation and duplicate create', async () => {
96
96
  const p = deferred<FigureProductRecord>(),
97
97
  s = createFigureProductSession({ ...store, save: () => p.promise })
98
- s.startNew()
98
+ s.startNew({ name: 'New' })
99
99
  const text = s.source(),
100
100
  saving = s.save()
101
- assert.throws(() => s.startNew())
101
+ assert.throws(() => s.startNew({ name: 'New' }))
102
102
  assert.throws(() => s.undo())
103
103
  await assert.rejects(s.save())
104
104
  p.resolve({ id: 'new', source: text })
@@ -124,7 +124,7 @@ test('retry after a lost create response reuses the same creation identity', asy
124
124
  return { id: q.creationId!, source: q.source }
125
125
  }
126
126
  })
127
- s.startNew()
127
+ s.startNew({ name: 'New' })
128
128
  await assert.rejects(s.save())
129
129
  await s.save()
130
130
  assert.equal(keys.length, 2)
@@ -67,14 +67,14 @@ test('a save names the proposals its text holds: undone, not; redone, again; und
67
67
  test('a new figure opened for a proposal is created under its key; one taken on the open new figure keeps its id', async () => {
68
68
  const { store, saves } = server()
69
69
  const s = createFigureProductSession(store)
70
- s.startNew()
70
+ s.startNew({ name: 'New' })
71
71
  s.createUnder(K1)
72
72
  s.applyProposal(lid, s.read().revision!, s.read().context!, K1)
73
73
  await s.save({ type: 'LIDDED' })
74
74
  assert.equal(saves.at(-1)!.creationId, K1, 'created under the proposal key: taken twice, it meets the first figure')
75
75
  assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
76
76
 
77
- s.startNew({ discard: true })
77
+ s.startNew({ discard: true, name: 'New' })
78
78
  const own = s.read().creationId
79
79
  const detail: FigureApplyDetail = { key: K2, steps: lid, create: true }
80
80
  assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
@@ -82,7 +82,7 @@ test('a new figure opened for a proposal is created under its key; one taken on
82
82
  assert.deepEqual({ creationId: saves.at(-1)!.creationId, proposalKeys: saves.at(-1)!.proposalKeys }, { creationId: own, proposalKeys: [K2] })
83
83
 
84
84
  /* A new figure the person has touched is theirs: the proposal is not put on it (NEW_FIGURE_UNSAVED), its id stays. */
85
- s.startNew({ discard: true })
85
+ s.startNew({ discard: true, name: 'New' })
86
86
  s.command({ kind: 'rename', name: 'Mine' } as never)
87
87
  assert.throws(() => s.createUnder(K2))
88
88
  /* Nor on a saved one. */
@@ -50,7 +50,7 @@ test('the source a revert expects is what the server is confirmed to hold -- not
50
50
  return { id: q.id ?? 'a', source: row }
51
51
  }
52
52
  })
53
- session.startNew()
53
+ session.startNew({ name: 'New' })
54
54
  assert.equal(session.confirmedSource(), undefined, 'nothing saved: nothing to revert over')
55
55
  await session.open('a')
56
56
  const loaded = row
@@ -15,9 +15,13 @@ import {
15
15
  figureSlotIssues,
16
16
  slotLayoutCommandOf,
17
17
  slotLayoutFormOf,
18
+ placeMarksOf,
18
19
  slotMarksOf,
19
20
  slotPresetsOf,
20
- SLOT_MARK
21
+ SLOT_MARK,
22
+ SLOT_MARK_CHOSEN,
23
+ startSizeOf,
24
+ anchorRoomOf
21
25
  } from '../client/modeller/slot-layout-host.js'
22
26
  import { editRefusalOf, slotLayoutRefusalOf } from '../client/modeller/refusals.js'
23
27
 
@@ -54,11 +58,13 @@ test("no layout yet: a form on the selected part's top, and the equipment preset
54
58
  assert.equal(slotPresetsOf([{ id: 'under', part: 'fork', face: 'bottom' }])[0]!.build(size, {}).tiers[0]!.mount, 'HANG')
55
59
  })
56
60
 
57
- test('the editor is told what figure-model asks beyond its own checks: every slot names a type', () => {
61
+ test('the editor is told what figure-model asks beyond its own checks: a slot names its types or takes any item, never an empty list', () => {
58
62
  assert.deepEqual(figureSlotIssues(twoAcross()), [])
59
- assert.deepEqual(figureSlotIssues(twoAcross([])), [{ code: 'ACCEPTS_REQUIRED', path: 'tiers[0].layout.slot.accepts' }])
63
+ assert.deepEqual(figureSlotIssues(twoAcross([])), [{ code: 'ACCEPTS_EMPTY', path: 'tiers[0].layout.slot.accepts' }])
60
64
  const custom: SlotLayout = { tiers: [{ ...twoAcross().tiers[0]!, layout: { pattern: 'CUSTOM', slots: [{ slotNo: 1, offset: { x: 0, z: 0 }, allowed: { width: 1, depth: 1, height: 1 } }] } }] }
61
- assert.deepEqual(figureSlotIssues(custom), [{ code: 'ACCEPTS_REQUIRED', path: 'tiers[0].layout.slots[0].accepts' }])
65
+ assert.deepEqual(figureSlotIssues(custom), [])
66
+ const empty: SlotLayout = { tiers: [{ ...twoAcross().tiers[0]!, layout: { pattern: 'CUSTOM', slots: [{ slotNo: 1, offset: { x: 0, z: 0 }, allowed: { width: 1, depth: 1, height: 1 }, accepts: [] }] } }] }
67
+ assert.deepEqual(figureSlotIssues(empty), [{ code: 'ACCEPTS_EMPTY', path: 'tiers[0].layout.slots[0].accepts' }])
62
68
  })
63
69
 
64
70
  test('applied, it is one command that figure-model takes, and the figure reads back as the form it came from', () => {
@@ -91,6 +97,42 @@ test('the unfolded slots are drawn as boxes of what they allow, on the fork, nev
91
97
  assert.deepEqual(slotMarksOf(evaluation.bindings as never, undefined), [])
92
98
  })
93
99
 
100
+ test('places held one by one are drawn too: a sized one as its box out of its face, one without as its face raised as high as a slot would start there, the selected part\'s in the primary colour', () => {
101
+ let asset = addFigureBox(createFigureAsset('Lift'), { id: 'plate', size: { x: 800, y: 50, z: 1000 }, position: { x: 0, y: 225, z: 0 } })
102
+ asset = applyFigureAuthoring(asset, { kind: 'add-binding', id: 'seat', label: 'Seat', role: 'seat', part: 'plate', face: 'top', capacity: 1 } as never)
103
+ asset = applyFigureAuthoring(asset, { kind: 'add-binding', id: 'under', label: 'Under', role: 'grip', part: 'plate', face: 'bottom', capacity: 1 } as never)
104
+ asset = applyFigureAuthoring(asset, { kind: 'add-binding', id: 'side', label: 'Side', role: 'seat', part: 'plate', face: 'right', capacity: 1, region: { x: 100, y: 200, z: 300 } } as never)
105
+ const evaluation = compileFigureAsset(asset).evaluate()
106
+ const marks = placeMarksOf(evaluation.bindings as never, asset, evaluation.geometry as never) as any[]
107
+ assert.deepEqual(marks.map(m => m.id), [`${SLOT_MARK}:seat`, `${SLOT_MARK}:under`, `${SLOT_MARK}:side`])
108
+ const [seat, under, side] = marks
109
+ /* The plate's top, the figure's top: as wide and deep as the face, as high as its shorter side, standing on it. */
110
+ assert.deepEqual(seat.shape.extrusion.section.size, { x: 800, z: 1000 })
111
+ assert.deepEqual([seat.shape.extrusion.start, seat.shape.extrusion.end], [0, 800])
112
+ assert.equal(Math.round(seat.pose.t[1]), 250)
113
+ /* Its bottom: the same box, under the face. */
114
+ assert.deepEqual([under.shape.extrusion.start, under.shape.extrusion.end], [-800, 0])
115
+ assert.equal(Math.round(under.pose.t[1]), 200)
116
+ /* A sized place on the right face: its own box, out along +X. */
117
+ assert.equal(side.shape.extrusion.axis, 'x')
118
+ assert.deepEqual(side.shape.extrusion.section.size, { y: 200, z: 300 })
119
+ assert.deepEqual([side.shape.extrusion.start, side.shape.extrusion.end], [0, 100])
120
+ assert.ok(marks.every(m => m.material.token === SLOT_MARK && m.material.transparent))
121
+ const chosen = placeMarksOf(evaluation.bindings as never, asset, evaluation.geometry as never, 'plate') as any[]
122
+ assert.ok(chosen.every(m => m.material.token === SLOT_MARK_CHOSEN))
123
+ /* With room over the face -- a mast over a fork -- the place is as high as that room, as a slot would start. */
124
+ let mast = addFigureBox(createFigureAsset('Truck'), { id: 'fork', size: { x: 1000, y: 50, z: 800 }, position: { x: 0, y: 25, z: 0 } })
125
+ mast = addFigureBox(mast, { id: 'mast', size: { x: 100, y: 2000, z: 100 }, position: { x: 0, y: 1000, z: -600 } })
126
+ mast = applyFigureAuthoring(mast, { kind: 'add-binding', id: 'forks', label: 'Forks', role: 'seat', part: 'fork', face: 'top', capacity: 1 } as never)
127
+ const truck = compileFigureAsset(mast).evaluate()
128
+ const [forks] = placeMarksOf(truck.bindings as never, mast, truck.geometry as never) as any[]
129
+ assert.deepEqual([forks.shape.extrusion.start, forks.shape.extrusion.end], [0, 1950])
130
+ /* The slot layout's places are its own marks', not drawn twice. */
131
+ const slotted = applyFigureAuthoring(stacker(), slotLayoutCommandOf({ label: 'Forks', role: 'seat', anchors: [{ id: 'fork', part: 'fork', face: 'top' }] }, twoAcross(), '') as never)
132
+ const unfolded = compileFigureAsset(slotted).evaluate()
133
+ assert.deepEqual(placeMarksOf(unfolded.bindings as never, slotted, unfolded.geometry as never), [])
134
+ })
135
+
94
136
  test('refusals are said from their details, never the kernel\'s sentence', () => {
95
137
  const refusal = (details: object) => Object.assign(new Error('KERNEL ENGLISH'), { code: 'X', details })
96
138
  assert.equal(slotLayoutRefusalOf(refusal({ reason: 'slot-anchor-not-flat', anchor: 'a', part: 'fork', face: 'front' }), say), 'figure.text.slot-anchor-not-flat{"anchor":"a","part":"fork"}')
@@ -101,6 +143,22 @@ test('refusals are said from their details, never the kernel\'s sentence', () =>
101
143
  assert.equal(editRefusalOf(Object.assign(refusal({ reason: 'slot-id-taken', slot: 't1_s1' }), { code: 'SLOT_LAYOUT' }), say), 'figure.text.slot-id-taken{"slot":"t1_s1"}')
102
144
  })
103
145
 
146
+ test('where an author starts with no numbers: the first face, a slot as high as the room over it, the face\'s shorter side at the figure\'s top', () => {
147
+ const plain = stacker()
148
+ const anchors = [{ id: 'fork', part: 'fork', face: 'top' as const }]
149
+ const geometry = (asset: typeof plain) => compileFigureAsset(asset).evaluate().geometry as never
150
+ /* The fork is the stacker's top: no room over it, so the face's shorter side. */
151
+ assert.deepEqual(startSizeOf(anchors, anchorFacesOf(anchors, geometry(plain)), anchorRoomOf(anchors, geometry(plain))), { width: 1200, depth: 900, slotHeight: 900 })
152
+ /* A mast 1500 high behind it: the slot is as high as the room up to the mast's top. */
153
+ const masted = addFigureBox(plain, { id: 'mast', size: { x: 100, y: 1500, z: 100 }, position: { x: 0, y: 750, z: -500 } })
154
+ assert.deepEqual(startSizeOf(anchors, anchorFacesOf(anchors, geometry(masted)), anchorRoomOf(anchors, geometry(masted))), { width: 1200, depth: 900, slotHeight: 1450 })
155
+ assert.deepEqual(startSizeOf(anchors, {}, {}), {})
156
+ /* The panel gives the editor that size and starts it from the single slot. */
157
+ const panel = read('../client/modeller/figure-slot-layout-panel.ts')
158
+ assert.match(panel, /presetSize: startSizeOf\(form\.anchors, anchorFaces, anchorRoomOf\(form\.anchors, geometry\)\)/)
159
+ assert.match(panel, /startFrom: 'single'/)
160
+ })
161
+
104
162
  test('the slot layout panel starts from presets that keep where they started as a record', () => {
105
163
  const [single] = slotPresetsOf([{ id: 'fork', part: 'fork', face: 'top' }])
106
164
  assert.deepEqual(single!.build({ width: 1200, depth: 900, slotHeight: 1000 }, {}).preset, { key: 'single', size: { width: 1200, depth: 900, slotHeight: 1000 }, params: {} })
@@ -110,7 +168,7 @@ test('the slot layout panel starts from presets that keep where they started as
110
168
  test('the places words are in every language', () => {
111
169
  for (const lang of ['en', 'es', 'ja', 'ko', 'ms', 'zh']) {
112
170
  const t = JSON.parse(read(`../translations/${lang}.json`))
113
- for (const key of ['figure.label.places-one', 'figure.label.places-by-rule', 'figure.text.slot-accepts-required', 'figure.text.slot-anchor-not-flat', 'figure.text.slot-id-taken', 'figure.button.remove-slot-layout'])
171
+ for (const key of ['figure.label.places-one', 'figure.label.places-by-rule', 'figure.text.slot-accepts-empty-list', 'figure.text.slot-anchor-not-flat', 'figure.text.slot-id-taken', 'figure.button.remove-slot-layout'])
114
172
  assert.ok(t[key], `${lang} ${key}`)
115
173
  }
116
174
  })