@things-factory/figure-ui 10.1.222 → 10.1.224

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 (59) 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.ts +14 -7
  5. package/client/modeller/figure-capability-panel.ts +145 -1
  6. package/client/modeller/figure-data-panel.ts +495 -188
  7. package/client/modeller/figure-links.ts +130 -12
  8. package/client/modeller/figure-preview.ts +19 -2
  9. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  10. package/client/modeller/links.ts +58 -0
  11. package/client/modeller/refusals.ts +12 -4
  12. package/client/modeller/slot-layout-host.ts +76 -5
  13. package/dist-client/modeller/canvas.js +2 -2
  14. package/dist-client/modeller/canvas.js.map +1 -1
  15. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  16. package/dist-client/modeller/consumer-capabilities.js +60 -0
  17. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  18. package/dist-client/modeller/data-interface.d.ts +83 -17
  19. package/dist-client/modeller/data-interface.js +194 -82
  20. package/dist-client/modeller/data-interface.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +5 -2
  22. package/dist-client/modeller/editor.js +16 -5
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  25. package/dist-client/modeller/figure-capability-panel.js +123 -1
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  28. package/dist-client/modeller/figure-data-panel.js +461 -180
  29. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  30. package/dist-client/modeller/figure-links.d.ts +11 -0
  31. package/dist-client/modeller/figure-links.js +137 -16
  32. package/dist-client/modeller/figure-links.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +4 -0
  34. package/dist-client/modeller/figure-preview.js +19 -1
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/links.d.ts +22 -0
  39. package/dist-client/modeller/links.js +48 -0
  40. package/dist-client/modeller/links.js.map +1 -1
  41. package/dist-client/modeller/refusals.js +14 -6
  42. package/dist-client/modeller/refusals.js.map +1 -1
  43. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  44. package/dist-client/modeller/slot-layout-host.js +68 -6
  45. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +9 -9
  48. package/test/browser/data-table-page.js +9 -0
  49. package/test/browser/links.check.mjs +25 -3
  50. package/test/consumer-capabilities.test.ts +116 -0
  51. package/test/data-motion.test.ts +94 -27
  52. package/test/links.test.ts +15 -0
  53. package/test/slot-layout-host.test.ts +63 -5
  54. package/translations/en.json +81 -23
  55. package/translations/es.json +81 -23
  56. package/translations/ja.json +81 -23
  57. package/translations/ko.json +81 -23
  58. package/translations/ms.json +81 -23
  59. package/translations/zh.json +81 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.222",
3
+ "version": "10.1.224",
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.11.1",
31
- "@hatiolab/things-scene": "^10.1.101",
30
+ "@hatiolab/figure-model": "^0.12.0",
31
+ "@hatiolab/things-scene": "^10.1.102",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.43",
34
34
  "@operato/graphql": "^10.29.43",
@@ -39,14 +39,14 @@
39
39
  "@operato/p13n": "^10.29.43",
40
40
  "@operato/popup": "^10.29.43",
41
41
  "@operato/shell": "^10.29.43",
42
- "@operato/slot-layout-editor": "^10.29.43",
42
+ "@operato/slot-layout-editor": "^10.29.57",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.222",
46
- "@things-factory/figure-service": "^10.1.222",
47
- "@things-factory/figure-viewer": "^10.1.222",
48
- "@things-factory/setting-base": "^10.1.218",
45
+ "@things-factory/ai-assistant": "^10.1.223",
46
+ "@things-factory/figure-service": "^10.1.224",
47
+ "@things-factory/figure-viewer": "^10.1.224",
48
+ "@things-factory/setting-base": "^10.1.223",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "afdd6a9b1c8b440e39d4fec48bfbad1a0c02fccc"
51
+ "gitHead": "7edcdca6fc9862973e99048812e22f2aa9b2f9c9"
52
52
  }
@@ -0,0 +1,9 @@
1
+ /* Manual check of the data tab's tables (ADR-0166): the real editor on the robot dog, in Korean, over a store in memory. */
2
+ import './modeller-page.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { robotDogFigure, serializeFigureAsset } from '@hatiolab/figure-model'
5
+
6
+ const ko = await fetch('/ko.json').then(r => r.json())
7
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
8
+ await i18next.changeLanguage('ko')
9
+ await window.harness.mount('dog', serializeFigureAsset(robotDogFigure().asset))
@@ -350,13 +350,14 @@ describe('a row\'s pitch', () => {
350
350
  })
351
351
 
352
352
  describe('holding places on a part', () => {
353
- test('a place needs kinds it takes; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
353
+ test('a place takes any item until kinds are named; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
354
354
  await mount('box')
355
355
  await pick('box')
356
356
  /* What the figure does is a tab again (user 2026-10-17). */
357
357
  await tab('capability')
358
- assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, true, 'no kinds: no place')
359
- await type(await handle('input[placeholder="BOX, TOTE"]'), 'BOX')
358
+ /* No kinds named: it takes any item (ADR-0051 §4.1 decision 5, 2026-10-27). */
359
+ assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'no kinds: a place for any item')
360
+ await type(await handle('input[placeholder="figure.text.place-accepts-any"]'), 'BOX')
360
361
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'a kind: a place may be made')
361
362
  assert.equal((await all('p[hint]', 'figure.text.place-needs-holding')).length, 1, 'said: nothing is passed without holding')
362
363
  const start = await state()
@@ -365,6 +366,27 @@ describe('holding places on a part', () => {
365
366
  assert.ok((await source()).document.capabilities.names.includes('Capacity'))
366
367
  assert.deepEqual(await all('p[hint]', 'figure.text.place-needs-holding'), [], 'not said again')
367
368
  })
369
+
370
+ test('a place is changed under its own id: its types emptied, it takes any item, in one step', async () => {
371
+ let a = box(createFigureAsset('Gripper'), 'box', [200, 100, 200], [0, 50, 0])
372
+ a = applyFigureAuthoring(a, { kind: 'add-binding', id: 'box-grip-old', label: 'Grip', role: 'grip', part: 'box', face: 'top', accepts: ['BOX', 'WORKPIECE'], capacity: 1 })
373
+ await mount('box', a)
374
+ await pick('box')
375
+ await tab('capability')
376
+ await click(await at('button[edit-place]'), 'change the place')
377
+ const field = await handle('input[placeholder="figure.text.place-accepts-any"]')
378
+ assert.equal(await field.evaluate(i => i.value), 'BOX, WORKPIECE', 'its types are in the form')
379
+ await field.click({ clickCount: 3 })
380
+ await page().keyboard.press('Backspace')
381
+ await page().keyboard.press('Tab')
382
+ await settled()
383
+ const start = await state()
384
+ await click(await at('button[act]', 'figure.button.save-place'), 'save the place')
385
+ await oneStep(start)
386
+ const places = (await source()).document.capabilities.bindings
387
+ assert.deepEqual(places.map(b => [b.id, b.label, b.role, Object.hasOwn(b, 'accepts')]), [['box-grip-old', 'Grip', 'grip', false]])
388
+ assert.ok(await at('button[act]', 'figure.button.add-place'), 'the form adds again')
389
+ })
368
390
  })
369
391
 
370
392
  describe('a position worked out from the figure size', () => {
@@ -0,0 +1,116 @@
1
+ /*
2
+ * A product's capabilities in the modeller (ADR-0167 3, 9, 12): the registered descriptors, what each still needs, its
3
+ * settings as the descriptor draws them, and the kernel taking what the panel writes.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import { addFigureBox, addFigurePrimitive, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
11
+ import { figureBindingsTakenOver, registerFigureCapability } from '@hatiolab/things-scene'
12
+ import {
13
+ consumerDescriptors,
14
+ inputTableAt,
15
+ namesWith,
16
+ needsMissing,
17
+ partsAt,
18
+ settingsIssues,
19
+ settingsOf,
20
+ unknownConsumerNames,
21
+ withInputTable,
22
+ withParts
23
+ } from '../client/modeller/consumer-capabilities.ts'
24
+
25
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
26
+ const FILL = { key: 'fill', kind: 'input-table', label: '적재량', keys: ['FULL', 'EMPTY', 'EMPTYEMPTY'], start: { FULL: 100, EMPTY: 0, EMPTYEMPTY: 0 } } as const
27
+ const CARRIER = {
28
+ name: 'acme.Carrier',
29
+ label: 'Carrier',
30
+ note: '캐리어',
31
+ version: '1',
32
+ needs: ['item'],
33
+ settings: [{ key: 'loadParts', kind: 'parts', label: '적재물', required: true }, FILL],
34
+ data: [{ name: 'EMPTYTYPE' }, { name: 'CARRIERSTATUS' }],
35
+ check: (settings: Record<string, unknown> | undefined) => (Array.isArray(settings?.loadParts) && settings.loadParts.length ? [] : ['적재물 부품을 고릅니다.'])
36
+ } as never
37
+
38
+ function reel() {
39
+ let asset = addFigurePrimitive(createFigureAsset('Reel'), { id: 'wound', kind: 'cylinder', dimensions: { radius: 400, length: 300 }, position: { x: 0, y: 400, z: 0 } })
40
+ asset.document.model.inputs.push({ id: 'fill', unit: 'percent', min: 0, max: 100, role: 'state' })
41
+ asset.stateDefaults['fill'] = 100
42
+ return asset
43
+ }
44
+
45
+ test('the registered products\' descriptors; one the figure wears that this modeller does not know is said', () => {
46
+ const off = registerFigureCapability(CARRIER)
47
+ try {
48
+ assert.deepEqual(consumerDescriptors().map(d => d.name), ['acme.Carrier'], 'the shared four are the ladder, not here')
49
+ const asset = editFigurePart(reel(), { kind: 'set-capabilities', names: ['acme.Carrier', 'other.Gauge'] })
50
+ assert.deepEqual(unknownConsumerNames(asset), ['other.Gauge'])
51
+ } finally {
52
+ off()
53
+ }
54
+ })
55
+
56
+ test('what a capability still needs: the item mark, a name; put on and off keeps the other names', () => {
57
+ const asset = reel()
58
+ assert.deepEqual(needsMissing(CARRIER, asset), ['item'])
59
+ assert.deepEqual(needsMissing(CARRIER, { ...asset, item: true }), [])
60
+ assert.deepEqual(needsMissing({ ...(CARRIER as object), needs: ['Holdable', ['Capacity', 'FlowNode']] } as never, asset), ['Holdable', 'Capacity | FlowNode'])
61
+ const held = editFigurePart(asset, { kind: 'set-capabilities', names: ['Capacity', 'Holdable'] })
62
+ assert.deepEqual(namesWith(held, 'acme.Carrier', true), ['Capacity', 'Holdable', 'acme.Carrier'])
63
+ assert.deepEqual(namesWith(editFigurePart(held, { kind: 'set-capabilities', names: namesWith(held, 'acme.Carrier', true) }), 'acme.Carrier', false), ['Capacity', 'Holdable'])
64
+ })
65
+
66
+ test('settings as the descriptor draws them: parts picked, an input with a value per fixed key; the kernel takes them', () => {
67
+ let asset = editFigurePart({ ...reel(), item: true }, { kind: 'set-capabilities', names: ['acme.Carrier'] })
68
+ assert.deepEqual(settingsIssues(CARRIER, asset), ['적재물 부품을 고릅니다.'])
69
+ let settings = withParts(settingsOf(asset, 'acme.Carrier'), 'loadParts', ['wound'])
70
+ assert.deepEqual(partsAt(settings, 'loadParts'), ['wound'])
71
+ /* No input yet: the fixed keys start at the descriptor's values. */
72
+ assert.deepEqual(inputTableAt(settings, FILL), { values: { FULL: 100, EMPTY: 0, EMPTYEMPTY: 0 } })
73
+ settings = withInputTable(settings, FILL, 'fill', { ...inputTableAt(settings, FILL).values, EMPTY: 5 })
74
+ assert.deepEqual(settings, { loadParts: ['wound'], fill: { input: 'fill', values: { FULL: 100, EMPTY: 5, EMPTYEMPTY: 0 } } })
75
+ asset = editFigurePart(asset, { kind: 'set-capability-settings', name: 'acme.Carrier', settings })
76
+ assert.deepEqual(settingsIssues(CARRIER, asset), [])
77
+ assert.deepEqual(inputTableAt(settingsOf(asset, 'acme.Carrier'), FILL), { input: 'fill', values: { FULL: 100, EMPTY: 5, EMPTYEMPTY: 0 } })
78
+ /* No input: the table goes; no parts: the key goes. */
79
+ assert.deepEqual(withInputTable(settings, FILL, undefined, {}), { loadParts: ['wound'] })
80
+ assert.deepEqual(withParts(settings, 'loadParts', []), { fill: settings.fill })
81
+ })
82
+
83
+ test('the panel writes through the kernel\'s commands, the ladder keeps a product\'s names, and every word is there', () => {
84
+ const panel = read('../client/modeller/figure-capability-panel.ts')
85
+ assert.match(panel, /kind: 'set-capability-settings', name: d\.name/)
86
+ assert.match(panel, /\[\.\.\.RUNGS\[which\], \.\.\.consumers\]/)
87
+ const keys = [read('../client/modeller/figure-capability-panel.ts'), read('../client/modeller/figure-data-panel.ts')].flatMap(src => [...src.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!)).filter(k => /product-capabilit|data-capability|data-add-selected-part/.test(k))
88
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
89
+ const table = JSON.parse(read(`../translations/${lang}.json`))
90
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
91
+ }
92
+ })
93
+
94
+ test('a binding a product capability takes over is marked where it rests, whole or for the targets named (ADR-0167 17)', () => {
95
+ const off = registerFigureCapability({ name: 'acme.Lamp', label: 'Lamp', note: '', version: '1', needs: [], takes: settings => ({ parts: (settings?.parts as string[]) ?? [] }) })
96
+ try {
97
+ let asset = addFigureBox(addFigureBox(createFigureAsset('Lamp post'), { id: 'head', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 450, z: 0 } }), { id: 'pole', size: { x: 20, y: 400, z: 20 }, position: { x: 0, y: 200, z: 0 } })
98
+ asset = editFigurePart(asset, { kind: 'set-capabilities', names: ['acme.Lamp'] })
99
+ asset = editFigurePart(asset, { kind: 'set-capability-settings', name: 'acme.Lamp', settings: { parts: ['head'] } })
100
+ asset = editFigurePart(asset, {
101
+ kind: 'set-data-binding',
102
+ schema: [{ name: 'mode', kind: 'text' }],
103
+ binding: { id: 'look', accessor: 'mode', rule: 'table', lookup: 'code', targets: { parts: ['head', 'pole'] }, table: { default: { color: '#111111' } } }
104
+ })
105
+ assert.deepEqual(figureBindingsTakenOver(asset), [{ id: 'look', capability: 'acme.Lamp', whole: false, parts: ['head'], inputs: [] }])
106
+ } finally {
107
+ off()
108
+ }
109
+ const panel = read('../client/modeller/figure-data-panel.ts')
110
+ assert.match(panel, /const resting = figureBindingsTakenOver\(asset\)/)
111
+ assert.match(panel, /resting\.filter\(r => r\.id === b\.id\)\.map\(r => this\.renderResting\(r\)\)/)
112
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
113
+ const table = JSON.parse(read(`../translations/${lang}.json`))
114
+ for (const key of ['figure.text.data-binding-rests', 'figure.text.data-binding-rests-targets']) assert.match(table[key], /\{where\}.*\{name\}/, `${lang}: ${key}`)
115
+ }
116
+ })
@@ -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
  ]
@@ -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
+ })
@@ -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
  })