@things-factory/figure-ui 10.1.271 → 10.1.273

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 (112) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/data-interface.ts +30 -18
  4. package/client/modeller/editor-styles.ts +17 -6
  5. package/client/modeller/editor.ts +2 -0
  6. package/client/modeller/fasten-form.ts +81 -6
  7. package/client/modeller/fasten-geometry.ts +5 -3
  8. package/client/modeller/figure-cavity-drawing.ts +1 -3
  9. package/client/modeller/figure-clips.ts +101 -8
  10. package/client/modeller/figure-data-panel.ts +384 -200
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +24 -14
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +109 -37
  15. package/client/modeller/figure-parts.ts +7 -11
  16. package/client/modeller/figure-preview.ts +299 -79
  17. package/client/modeller/figure-size-editor.ts +7 -2
  18. package/client/modeller/first-save.ts +8 -0
  19. package/client/modeller/focus-on-render.ts +4 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/size-response.ts +14 -0
  22. package/client/modeller/stage-model.ts +50 -6
  23. package/dist-client/modeller/canvas.d.ts +1 -0
  24. package/dist-client/modeller/canvas.js +3 -2
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +17 -3
  27. package/dist-client/modeller/clips.js +69 -4
  28. package/dist-client/modeller/clips.js.map +1 -1
  29. package/dist-client/modeller/data-interface.d.ts +11 -8
  30. package/dist-client/modeller/data-interface.js +27 -17
  31. package/dist-client/modeller/data-interface.js.map +1 -1
  32. package/dist-client/modeller/editor-styles.js +17 -6
  33. package/dist-client/modeller/editor-styles.js.map +1 -1
  34. package/dist-client/modeller/editor.js +2 -0
  35. package/dist-client/modeller/editor.js.map +1 -1
  36. package/dist-client/modeller/fasten-form.d.ts +13 -0
  37. package/dist-client/modeller/fasten-form.js +72 -7
  38. package/dist-client/modeller/fasten-form.js.map +1 -1
  39. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  40. package/dist-client/modeller/fasten-geometry.js +5 -3
  41. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  42. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  43. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  44. package/dist-client/modeller/figure-clips.d.ts +9 -0
  45. package/dist-client/modeller/figure-clips.js +104 -8
  46. package/dist-client/modeller/figure-clips.js.map +1 -1
  47. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  48. package/dist-client/modeller/figure-data-panel.js +382 -190
  49. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  51. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +24 -14
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-first-save.js +4 -3
  55. package/dist-client/modeller/figure-first-save.js.map +1 -1
  56. package/dist-client/modeller/figure-links.d.ts +9 -0
  57. package/dist-client/modeller/figure-links.js +120 -51
  58. package/dist-client/modeller/figure-links.js.map +1 -1
  59. package/dist-client/modeller/figure-parts.js +7 -11
  60. package/dist-client/modeller/figure-parts.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.d.ts +13 -0
  62. package/dist-client/modeller/figure-preview.js +296 -80
  63. package/dist-client/modeller/figure-preview.js.map +1 -1
  64. package/dist-client/modeller/figure-size-editor.js +7 -3
  65. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  66. package/dist-client/modeller/first-save.d.ts +5 -0
  67. package/dist-client/modeller/first-save.js +7 -0
  68. package/dist-client/modeller/first-save.js.map +1 -1
  69. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  70. package/dist-client/modeller/focus-on-render.js +4 -0
  71. package/dist-client/modeller/focus-on-render.js.map +1 -0
  72. package/dist-client/modeller/pivot.d.ts +12 -0
  73. package/dist-client/modeller/pivot.js +59 -0
  74. package/dist-client/modeller/pivot.js.map +1 -0
  75. package/dist-client/modeller/size-response.d.ts +6 -0
  76. package/dist-client/modeller/size-response.js +14 -0
  77. package/dist-client/modeller/size-response.js.map +1 -1
  78. package/dist-client/modeller/stage-model.d.ts +17 -3
  79. package/dist-client/modeller/stage-model.js +44 -6
  80. package/dist-client/modeller/stage-model.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +8 -7
  83. package/test/browser/editing-page.js +1 -1
  84. package/test/browser/editing.check.mjs +6 -5
  85. package/test/browser/links.check.mjs +30 -0
  86. package/test/browser/modeller.check.mjs +2 -2
  87. package/test/browser/parts-page.js +4 -0
  88. package/test/browser/parts.check.mjs +16 -10
  89. package/test/browser/screen-page.js +12 -1
  90. package/test/browser/screen.check.mjs +142 -11
  91. package/test/browser/section.check.mjs +21 -0
  92. package/test/browser/serve-response.mjs +5 -2
  93. package/test/browser/side-panel-sweep.check.mjs +171 -10
  94. package/test/clips.test.ts +49 -4
  95. package/test/data-motion.test.ts +39 -8
  96. package/test/fasten-form.test.ts +69 -0
  97. package/test/fasten-geometry.test.ts +13 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/size-response.test.ts +10 -2
  101. package/test/stage-model.test.ts +23 -1
  102. package/translations/en.json +31 -11
  103. package/translations/es.json +30 -10
  104. package/translations/ja.json +30 -10
  105. package/translations/ko.json +32 -12
  106. package/translations/ms.json +30 -10
  107. package/translations/zh.json +30 -10
  108. package/tutorial/humanoid/README.md +22 -0
  109. package/tutorial/humanoid/chapters.mjs +247 -0
  110. package/tutorial/humanoid/encode.mjs +26 -0
  111. package/tutorial/humanoid/lib.mjs +197 -0
  112. package/tutorial/humanoid/record.mjs +65 -0
@@ -10,7 +10,7 @@
10
10
  import { test, describe, before, after } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
- import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
13
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
14
 
15
15
  import { buildPage } from './build.mjs'
16
16
  import { openPage } from './serve.mjs'
@@ -116,6 +116,17 @@ describe('the side panel', () => {
116
116
  assert.equal(hit, 'heading')
117
117
  })
118
118
 
119
+ test('the heading is one line, the part\'s name and kind side by side, on a ground the rows under it do not show through (user 2026-11-12)', async () => {
120
+ const head = await read(DEEP => {
121
+ const subject = eval(DEEP)('div[subject]')[0]
122
+ const [name, kind] = [subject.querySelector('b'), subject.querySelector('small')]
123
+ const middle = el => { const r = el.getBoundingClientRect(); return Math.round(r.top + r.height / 2) }
124
+ return { oneLine: Math.abs(middle(name) - middle(kind)) <= 3, ground: getComputedStyle(subject).backgroundColor }
125
+ }, DEEP)
126
+ assert.equal(head.oneLine, true)
127
+ assert.ok(!/rgba\(.*, 0\)$|transparent/.test(head.ground), `a solid ground: ${head.ground}`)
128
+ })
129
+
119
130
  test('U5 the motion tab: no animation yet is said in its own words, the controls\' words said once', async () => {
120
131
  await click(await tab('motion'), 'the motion tab')
121
132
  const hints = await read(DEEP => eval(DEEP)('p[hint]').map(p => p.textContent.trim()), DEEP)
@@ -125,6 +136,7 @@ describe('the side panel', () => {
125
136
 
126
137
  test('U6 the data tab: a field name and Enter adds the field', async () => {
127
138
  await click(await tab('data'), 'the data tab')
139
+ assert.equal(await read(DEEP => eval(DEEP)('details[add-field]')[0]?.open, DEEP), true, 'no field yet: the form stands open')
128
140
  await click(await read(() => look.at('input[aria-label="figure.label.data-name"]')), 'the field name')
129
141
  await page().keyboard.type('speed')
130
142
  await page().keyboard.press('Enter')
@@ -143,27 +155,49 @@ describe('the side panel', () => {
143
155
 
144
156
  describe('the preview', () => {
145
157
  /* A number and a text, of names far apart in length, each read by a table: the preview gives each a field. */
146
- const asset = addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
158
+ /* Its size declared, so the preview offers the size help over it (shown only where the size can change). */
159
+ const asset = applyFigureAuthoring(addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }), { kind: 'declare-instance-size', at: { x: 400, y: 400, z: 400 } })
147
160
  asset.dataInterface = {
148
161
  version: 'figure-data-interface-2',
149
162
  schema: [
150
163
  { name: 'A', kind: 'number' },
151
- { name: 'CARRIERSTATUS', kind: 'text' }
164
+ { name: 'CARRIERSTATUS', kind: 'text' },
165
+ { name: 'LOW', kind: 'boolean' },
166
+ { name: 'HEADING', kind: 'number', unit: 'deg' }
152
167
  ],
153
168
  bindings: [
154
169
  { id: 'a', accessor: 'A', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~10': { color: '#ff0000' }, default: { color: '#0000ff' } } },
170
+ { id: 'h', accessor: 'HEADING', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~180': { color: '#00ffff' }, default: { color: '#ffff00' } } },
171
+ { id: 'l', accessor: 'LOW', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { true: { color: '#ff00ff' }, default: { color: '#0000ff' } } },
155
172
  { id: 's', accessor: 'CARRIERSTATUS', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { X: { color: '#00ff00' } } }
156
173
  ]
157
174
  }
158
175
  before(async () => {
159
176
  await mount('box', serializeFigureAsset(asset))
160
177
  await click(await read(() => probe.modeButton('preview')), 'the preview')
161
- await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] input').length === 2, [DEEP], 'the two values in the preview')
178
+ await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] label input').length === 5, [DEEP], 'the four values in the preview')
162
179
  })
163
180
 
164
- test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths', async () => {
165
- const lefts = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] input')].map(i => Math.round(i.getBoundingClientRect().left)), DEEP)
166
- assert.equal(lefts[0], lefts[1], `the fields start at ${lefts.join(' and ')}`)
181
+ test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths -- and end in one, with the help', async () => {
182
+ const edges = await read(DEEP => {
183
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
184
+ return [...root.querySelectorAll('div[clips] label input:not([type=checkbox]):not([type=range]):not([typed])'), root.querySelector('div[clips] div[setup] > button')].map(i => {
185
+ const r = i.getBoundingClientRect()
186
+ return { left: Math.round(r.left), right: Math.round(r.right) }
187
+ })
188
+ }, DEEP)
189
+ assert.equal(edges[0].left, edges[1].left, `the fields start at ${JSON.stringify(edges)}`)
190
+ /* A number with no range and a text are as wide as each other, to the help's right edge (user 2026-11-12: every width differed). */
191
+ assert.equal(edges[0].right, edges[1].right, `the fields end at ${JSON.stringify(edges)}`)
192
+ assert.equal(edges[0].right, edges[2].right, `the help ends where they do: ${JSON.stringify(edges)}`)
193
+ })
194
+ test('the names stay on one line: a unit is not put under its name (user 2026-11-12)', async () => {
195
+ const tall = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label > span:first-child')].map(s => {
196
+ const range = document.createRange()
197
+ range.selectNodeContents(s)
198
+ return new Set([...range.getClientRects()].map(r => Math.round(r.bottom))).size
199
+ }), DEEP)
200
+ assert.ok(tall.every(n => n === 1), `lines per name: ${tall}`)
167
201
  })
168
202
 
169
203
  test('U7 the size help\'s choice and button are in the bar\'s look, not the browser\'s grey', async () => {
@@ -183,13 +217,140 @@ describe('the preview', () => {
183
217
  test('a value the sample gives is shown as the sample\'s, not offered as a field; the sample off, the field is back', async () => {
184
218
  const rows = () => read(DEEP => {
185
219
  const root = eval(DEEP)('figure-preview')[0].shadowRoot
186
- return { fields: root.querySelectorAll('div[clips] input, div[clips] select').length, sampled: [...root.querySelectorAll('div[clips] label[sampled] output')].map(o => o.textContent.trim()) }
220
+ return { fields: root.querySelectorAll('div[clips] label input, div[clips] label select').length, sampled: [...root.querySelectorAll('div[clips] label[sampled] output')].map(o => o.textContent.trim()) }
187
221
  }, DEEP)
188
222
  await read(DEEP => (eval(DEEP)('figure-preview')[0].sample = { A: 4 }), DEEP)
189
223
  await until(DEEP => eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label[sampled]'), [DEEP], 'the sampled row')
190
- assert.deepEqual(await rows(), { fields: 1, sampled: ['4'] }, 'A from the sample, CARRIERSTATUS still a field')
224
+ assert.deepEqual(await rows(), { fields: 4, sampled: ['4'] }, 'A from the sample, the others still fields')
191
225
  await read(DEEP => (eval(DEEP)('figure-preview')[0].sample = null), DEEP)
192
226
  await until(DEEP => !eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label[sampled]'), [DEEP], 'the field back')
193
- assert.deepEqual(await rows(), { fields: 2, sampled: [] })
227
+ assert.deepEqual(await rows(), { fields: 5, sampled: [] })
228
+ })
229
+ test('an angle with no range is a dial, a slider over one turn and a box, as the controls\' angles are (user 2026-11-12)', async () => {
230
+ const row = await read(DEEP => {
231
+ const label = [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label')].find(l => l.querySelector('span').textContent.trim().startsWith('HEADING'))
232
+ const slider = label.querySelector('input[type=range]')
233
+ return { dial: !!label.querySelector('svg[dial]'), min: slider?.min, max: slider?.max, box: !!label.querySelector('input[typed]') }
234
+ }, DEEP)
235
+ assert.deepEqual(row, { dial: true, min: '0', max: '360', box: true })
236
+ })
237
+ test('a click on a number\'s name gives it nothing; dragged, it gives the value dragged to (user 2026-11-12)', async () => {
238
+ const name = () => read(DEEP => {
239
+ const label = [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] label')].find(l => l.querySelector('span').textContent.trim() === 'A')
240
+ const r = label.querySelector('span[scrub]').getBoundingClientRect()
241
+ return { x: r.left + 4, y: r.top + r.height / 2 }
242
+ }, DEEP)
243
+ const valuing = () => read(DEEP => eval(DEEP)('figure-preview')[0].valuing.A, DEEP)
244
+ const at = await name()
245
+ await page().mouse.click(at.x, at.y)
246
+ assert.equal(await valuing(), undefined, 'a click is not a value')
247
+ await page().mouse.move(at.x, at.y)
248
+ await page().mouse.down()
249
+ await page().mouse.move(at.x + 15, at.y)
250
+ await page().mouse.up()
251
+ assert.equal(await valuing(), 15, 'dragged 15 pixels from nothing given')
252
+ })
253
+ test('the data tab\'s fields are a table -- field, kind, what each moves -- one row a field (user 2026-11-12)', async () => {
254
+ await click(await tab('data'), 'the data tab')
255
+ const table = await until(DEEP => {
256
+ const fields = eval(DEEP)('div[fields]')[0]
257
+ if (!fields) return undefined
258
+ const cells = row => [...row.querySelectorAll(':scope > span')].map(c => c.textContent.trim().replace(/\s+/g, ' '))
259
+ return { head: cells(fields.querySelector(':scope > div[head]')), rows: [...fields.querySelectorAll(':scope > button[row]')].map(cells) }
260
+ }, [DEEP], 'the fields table')
261
+ assert.deepEqual(table.head, ['figure.label.data-column-field', 'figure.label.data-column-kind', 'figure.label.data-column-moves'])
262
+ assert.equal(table.rows.length, 4, 'one row a field')
263
+ assert.ok(table.rows.every(r => r.length === 3), JSON.stringify(table.rows))
264
+ /* What a field moves is named, not counted. */
265
+ assert.ok(table.rows.every(r => !r[2].includes('figure.label.data-bound')), JSON.stringify(table.rows))
266
+ const tops = await read(DEEP => [...eval(DEEP)('div[fields]')[0].querySelectorAll(':scope > button[row]')].map(r => [...r.children].map(c => Math.round(c.getBoundingClientRect().left))), DEEP)
267
+ assert.equal(new Set(tops.map(t => t.join())).size, 1, `the columns line up: ${JSON.stringify(tops)}`)
268
+ /* With fields there, the form to add one is folded under its summary; on a figure with none it stands open (U6). */
269
+ assert.equal(await read(DEEP => eval(DEEP)('details[add-field]')[0]?.open, DEEP), false, 'folded')
270
+ })
271
+ test('a field\'s row opened holds its bindings and a folded form to add one; no list of bindings apart (user 2026-11-12)', async () => {
272
+ await read(DEEP => [...eval(DEEP)('div[fields]')[0].querySelectorAll(':scope > button[row]')].find(r => r.textContent.trim().startsWith('A')).click(), DEEP)
273
+ const detail = await until(DEEP => {
274
+ const fields = eval(DEEP)('div[fields]')[0]
275
+ const d = fields.querySelector(':scope > div[detail]')
276
+ if (!d) return undefined
277
+ return {
278
+ lines: [...d.querySelectorAll('div[bindings] button[line] strong')].map(s => s.textContent.trim()),
279
+ folded: d.querySelector('details[add-binding]')?.open === false,
280
+ apart: [...fields.getRootNode().querySelectorAll('section > h3')].map(h => h.textContent.trim()).filter(t => t.startsWith('figure.label.data-bindings'))
281
+ }
282
+ }, [DEEP], 'the A row opened')
283
+ assert.deepEqual(detail, { lines: ['A'], folded: true, apart: [] })
284
+ })
285
+ test('a table\'s targets are a property list and each value a box of its settings, one under another (user 2026-11-12)', async () => {
286
+ /* The A row is open from the test before: open its binding. */
287
+ await read(DEEP => eval(DEEP)('div[fields]')[0].querySelector(':scope > div[detail] button[line]').click(), DEEP)
288
+ const editor = await until(DEEP => {
289
+ const d = eval(DEEP)('div[fields]')[0].querySelector(':scope > div[detail]')
290
+ const targets = d.querySelector('div[targets]')
291
+ if (!targets) return undefined
292
+ const cases = [...d.querySelectorAll('div[cases] > div[case]')].map(c => Math.round(c.getBoundingClientRect().left))
293
+ const names = [...d.querySelectorAll('div[props] > span:nth-child(odd)')].map(n => Math.round(n.getBoundingClientRect().height))
294
+ return {
295
+ targets: [...targets.querySelectorAll(':scope > span:not([none])')].map(t => t.textContent.trim()),
296
+ ticks: targets.querySelectorAll('input[type=checkbox]').length + d.querySelectorAll('div[chips]').length,
297
+ stacked: cases.length >= 2 && new Set(cases).size === 1,
298
+ oneLine: names.length > 0 && new Set(names).size === 1
299
+ }
300
+ }, [DEEP], 'the table editor')
301
+ assert.deepEqual(editor, { targets: ['figure.label.data-target-parts', 'box'], ticks: 0, stacked: true, oneLine: true })
302
+ })
303
+ test('a sample typed as JSON5 is read, in a box with room for it (user 2026-11-12)', async () => {
304
+ await click(await tab('data'), 'the data tab')
305
+ const box = await until(DEEP => eval(DEEP)('textarea[sample]')[0], [DEEP], 'the sample box')
306
+ assert.ok(box)
307
+ const seen = await read(async DEEP => {
308
+ const area = eval(DEEP)('textarea[sample]')[0]
309
+ area.value = "{ A: 7, CARRIERSTATUS: 'X', }"
310
+ area.dispatchEvent(new Event('input'))
311
+ const panel = area.getRootNode().host
312
+ await panel.updateComplete
313
+ return {
314
+ height: Math.round(area.getBoundingClientRect().height),
315
+ refused: [...panel.shadowRoot.querySelectorAll('p[hint]')].some(p => p.textContent.includes('figure.text.data-not-json'))
316
+ }
317
+ }, DEEP)
318
+ assert.equal(seen.refused, false, 'read, not refused as not JSON')
319
+ assert.ok(seen.height >= 240, `room for a record: ${seen.height}px`)
320
+ })
321
+ test('a yes or no is a check box in the fields\' column; checked, the figure is given true (user 2026-11-12)', async () => {
322
+ const box = await read(DEEP => {
323
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
324
+ const check = root.querySelector('div[clips] label input[type=checkbox]')
325
+ return { found: !!check, choices: root.querySelectorAll('div[clips] label select').length, left: Math.round(check.getBoundingClientRect().left), fields: Math.round(root.querySelector('div[clips] label input:not([type=checkbox])').getBoundingClientRect().left) }
326
+ }, DEEP)
327
+ assert.deepEqual(box, { found: true, choices: 0, left: box.fields, fields: box.fields }, 'no list of two words; the box where the fields start')
328
+ await read(DEEP => eval(DEEP)('figure-preview')[0].shadowRoot.querySelector('div[clips] label input[type=checkbox]').click(), DEEP)
329
+ assert.equal(await read(DEEP => eval(DEEP)('figure-preview')[0].valuing.LOW, DEEP), true)
330
+ })
331
+ })
332
+
333
+ describe('the data table\'s kind column', () => {
334
+ before(async () => {
335
+ const asset = addFigureBox(createFigureAsset('Kinds'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
336
+ asset.dataInterface = {
337
+ version: 'figure-data-interface-2',
338
+ schema: [
339
+ { name: 'MODE', kind: 'text', values: ['idle', 'walk', 'run'] },
340
+ { name: 'ANGLE', kind: 'number', unit: 'deg', min: -45, max: 45 }
341
+ ],
342
+ bindings: []
343
+ }
344
+ await mount('box', serializeFigureAsset(asset))
345
+ })
346
+
347
+ test('says the kind and unit on one line; the values and range in its title (user 2026-11-12)', async () => {
348
+ await click(await tab('data'), 'the data tab')
349
+ const cells = await until(DEEP => {
350
+ const rows = [...(eval(DEEP)('div[fields]')[0]?.querySelectorAll(':scope > button[row]') ?? [])]
351
+ return rows.length === 2 ? rows.map(r => { const c = r.children[1]; return { text: c.textContent.trim(), title: c.title } }) : undefined
352
+ }, [DEEP], 'the two rows')
353
+ assert.ok(cells.every(c => !/[~(]/.test(c.text)), `no range or values in the cell: ${JSON.stringify(cells)}`)
354
+ assert.ok(cells[0].title.includes('idle, walk, run') && cells[1].title.includes('-45~45'), `said in the title: ${JSON.stringify(cells)}`)
194
355
  })
195
356
  })
@@ -93,7 +93,7 @@ test('a track copied onto another control a while later; a clip copy takes a fre
93
93
  })
94
94
 
95
95
  /* Review 2026-11-09: a track starts from the pose, a pose is kept in one step, a clip on a played control joins a group. */
96
- import { newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
96
+ import { copyClipEdits, newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
97
97
 
98
98
  const applied = (asset: FigureAsset, edits: unknown[]) => edits.reduce<FigureAsset>((a, e) => editFigurePart(a, e as never), asset)
99
99
 
@@ -103,7 +103,7 @@ test('새 트랙은 지금 자세를 처음부터 끝까지 유지 — 범위
103
103
  assert.deepEqual(newTrackKeys({ min: 5, max: 9 }, undefined, 2), [{ seconds: 0, value: 5 }, { seconds: 2, value: 5 }], '값을 모르면 범위의 시작')
104
104
  /* The panel's wiring: the default button holds the pose, the range one is the preset. */
105
105
  const panel = read('../client/modeller/figure-clips.ts')
106
- assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\)\)/)
106
+ assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\), 'smooth'\)/)
107
107
  assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration, 'range'\)/)
108
108
  /* Several edits go up as one step, and the editor applies them as one. */
109
109
  assert.match(read('../client/modeller/editor.ts'), /<figure-clips[\s\S]*?@part-edits=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.applyRecipe\(e\.detail\.edits\)\)\}[\s\S]*?<\/figure-clips>/)
@@ -128,13 +128,36 @@ test('재생 중인 조작값에 새 클립 — 그룹 없는 클립과 새 동
128
128
  assert.deepEqual(applied(grouped, half.edits).motionGroups!.map(g => g.clips), [['닫기', '열기', '반열기']])
129
129
  })
130
130
 
131
+ test('이 자세 기억하기 — 되풀이 트랙의 처음과 끝은 한 순간: 둘 중 어디서 기억해도 두 키가 함께 바뀐다', () => {
132
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
133
+ const keysOf = (a: FigureAsset) => clipsOf(a)[0]!.tracks[0]!.keys
134
+ assert.equal(clipsOf(asset)[0]!.tracks[0]!.loop, 'wrap')
135
+
136
+ /* Kept at the end: the end reads as the start, yet the pose is kept -- at both ends, so the loop does not jump. */
137
+ const atEnd = rememberedPoseEdits(asset, 'walk', 4, { shift: 20, spin: 0 }, { shift: 0, spin: 0 })
138
+ assert.equal(atEnd.edits.length, 1)
139
+ assert.deepEqual(keysOf(applied(asset, atEnd.edits)), [{ seconds: 0, value: 20 }, { seconds: 2, value: 60 }, { seconds: 4, value: 20 }])
140
+
141
+ /* Kept at 0: the end key follows. */
142
+ const atStart = rememberedPoseEdits(asset, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 })
143
+ assert.deepEqual(keysOf(applied(asset, atStart.edits)), [{ seconds: 0, value: 10 }, { seconds: 2, value: 60 }, { seconds: 4, value: 10 }])
144
+
145
+ /* Both ends already there: nothing to keep. */
146
+ assert.deepEqual(rememberedPoseEdits(asset, 'walk', 4, { shift: 0, spin: 0 }, { shift: 0, spin: 0 }).edits, [])
147
+
148
+ /* A loop left open on purpose -- a ramp that starts over -- keeps its other end. */
149
+ const ramp = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
150
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 4, { shift: 50, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 0 }, { seconds: 4, value: 50 }])
151
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 10 }, { seconds: 4, value: 60 }])
152
+ })
153
+
131
154
  test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른 동작이 쓰는 조작값은 이름만', () => {
132
155
  const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
133
156
  const base = { shift: 30, spin: 0 }
134
157
  /* At 2 s the clip has shift at 30: unchanged, no key. spin moved to 45 since: a new track from 0 to 45 at 2 s. */
135
158
  const kept = rememberedPoseEdits(asset, 'cycle', 2, { shift: 30, spin: 45 }, base)
136
159
  assert.deepEqual(kept.skipped, [])
137
- assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }] }])
160
+ assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }], interpolation: 'smooth' }])
138
161
  const after = applied(asset, kept.edits)
139
162
  assert.equal(poseAt(after, 'cycle', 2).find(c => c.input === 'spin')!.value, 45)
140
163
 
@@ -148,7 +171,7 @@ test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른
148
171
  assert.deepEqual(rememberedPoseEdits(after, 'cycle', 2, { shift: 30, spin: 45 }, { shift: 30, spin: 45 }).edits, [])
149
172
 
150
173
  /* At 0 a new track holds the pose throughout. */
151
- assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }] }])
174
+ assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }], interpolation: 'smooth' }])
152
175
 
153
176
  /* spin played by another clip in no group: not taken, named. */
154
177
  const other = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'turn', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 90 }] })
@@ -174,3 +197,25 @@ test('키 사이 움직임 — 트랙마다 일정한 속도 · 부드럽게 ·
174
197
  assert.match(panel, /\['linear', 'smooth', 'step'\] as const/)
175
198
  assert.match(panel, /\?disabled=\$\{this\.locked \|\| same \|\| !!fits\}/)
176
199
  })
200
+
201
+ test('a clip in no group is copied in one step: a group is made with a name of its own, and both are in it (review 2026-11-11)', () => {
202
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
203
+ const after = applied(asset, copyClipEdits(asset, 'walk'))
204
+ assert.deepEqual(clipsOf(after).map(c => c.name), ['walk', 'walk-copy'])
205
+ assert.deepEqual(after.motionGroups!.map(g => [g.label, g.clips, g.defaultClip]), [['motion', ['walk', 'walk-copy'], 'walk']])
206
+ /* A second group takes the next free name. */
207
+ const taken = { ...asset, motionGroups: [{ id: 'motion', label: 'motion', clips: ['lift'], defaultClip: 'lift', transitionSeconds: 0.2 }] }
208
+ assert.equal((copyClipEdits(taken, 'walk', 'motion')[0] as { group: { label: string } }).group.label, 'motion 2')
209
+ /* Already in a group: only the copy. */
210
+ assert.equal(copyClipEdits(after, 'walk').length, 1)
211
+ })
212
+
213
+ test('a clip lists its tracks in the order of the controls, however its drivers were last written (tutorial 2026-11-12)', () => {
214
+ const keys = [{ seconds: 0, value: 0 }, { seconds: 2, value: 10 }]
215
+ let asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 2, keys })
216
+ asset = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'walk', duration: 2, keys })
217
+ /* The first track keyed again: its driver is written anew. */
218
+ const shift = clipsOf(asset)[0]!.tracks.find(t => t.state === 'shift')!
219
+ asset = editFigurePart(asset, trackEdit(clipsOf(asset)[0]!, shift, { keys: withKey(shift.keys, { seconds: 1, value: 5 }) }) as never)
220
+ assert.deepEqual(clipsOf(asset)[0]!.tracks.map(t => t.state), ['shift', 'spin'])
221
+ })
@@ -22,6 +22,7 @@ import {
22
22
  fieldOfKind,
23
23
  fitsField,
24
24
  freeIdOf,
25
+ freeNumberCodeOf,
25
26
  legendRoleOf,
26
27
  methodsOf,
27
28
  motionWrittenByData,
@@ -29,6 +30,7 @@ import {
29
30
  schemaFollowingCodes,
30
31
  tableColumnsOf,
31
32
  tableDraftOf,
33
+ tableWithKeep,
32
34
  tableWithKeys,
33
35
  tableWithLook,
34
36
  tableWithTargets,
@@ -97,7 +99,7 @@ test('작동 목록과 방법: 값 그대로 · 배율이 움직일 수 있는
97
99
  assert.deepEqual(fits({ name: 'o', kind: 'object', fields: [] }), [])
98
100
  assert.deepEqual(fits({ name: 'l', kind: 'list', item: { kind: 'object', fields: [] } }), ['items'])
99
101
  const choices = behaviorsOf(DOG)
100
- assert.deepEqual(methodsOf({ kind: 'number' }, choices), ['value', 'scale', 'ranges'])
102
+ assert.deepEqual(methodsOf({ kind: 'number' }, choices), ['value', 'scale', 'code', 'ranges'], '숫자도 코드 표 (ADR-0166 개정 1)')
101
103
  assert.deepEqual(methodsOf({ kind: 'text' }, choices), ['value', 'code'])
102
104
  assert.deepEqual(methodsOf({ kind: 'boolean' }, choices), ['value', 'code'])
103
105
  assert.deepEqual(methodsOf({ kind: 'list', item: { kind: 'text' } }, choices), ['value'])
@@ -169,21 +171,20 @@ test('샘플: 보드의 Figure 와 같은 읽기로 -- 무엇을 움직이는지
169
171
  assert.ok(Number.isNaN(typedValueOf(row('cadence'), '') as number), '빈 칸은 0 이 아니다')
170
172
  })
171
173
 
172
- test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간 -- 새 필드는 표가 만든다 (ADR-0166 10)', () => {
174
+ test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간 (ADR-0166 10)', () => {
173
175
  const mode = tableDraftOf(DOG, { path: 'mode', node: fieldAt(DOG.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'] })
174
- assert.deepEqual([mode.binding.id, mode.binding.accessor, mode.binding.table, mode.schema], ['mode', 'mode', { idle: {}, walk: {}, run: {} }, undefined])
176
+ assert.deepEqual([mode.binding.id, mode.binding.accessor, mode.binding.table], ['mode', 'mode', { idle: {}, walk: {}, run: {} }])
175
177
  editFigurePart(DOG, { kind: 'set-data-binding', binding: mode.binding })
176
178
  const open = tableDraftOf(DOG, { path: 'status', node: fieldAt(DOG.dataInterface!.schema, 'status')! }, 'code', { parts: ['head'] })
177
179
  assert.deepEqual(open.binding.table, {})
178
180
  const pct = tableDraftOf(DOG, { path: 'cadence', node: fieldAt(DOG.dataInterface!.schema, 'cadence')! }, 'ranges', { parts: ['body'] })
179
181
  assert.deepEqual(pct.binding.table, { '0~': {} })
180
182
  editFigurePart(DOG, { kind: 'set-data-binding', binding: pct.binding })
181
- /* 새 이름이면 필드를 만든다: 코드 표는 값 목록이 표의 키인 글자, 구간 표는 숫자. 한 걸음에 둘 다. */
182
- const made = tableDraftOf(DOG, { name: 'EMPTYTYPE' }, 'code', { parts: ['body'] })
183
- assert.deepEqual(made.schema!.at(-1), { name: 'EMPTYTYPE', kind: 'text', values: ['CODE'] })
183
+ /* 표는 표의 행이 연 필드에 붙는다: 필드는 먼저 등록한다 (데이터 탭이 필드마다 연결을 둔다, 2026-11-12). */
184
+ const typed = withField(DOG, { name: 'EMPTYTYPE', kind: 'text', values: ['CODE'] })
185
+ const made = tableDraftOf(typed, { path: 'EMPTYTYPE', node: fieldAt(typed.dataInterface!.schema, 'EMPTYTYPE')! }, 'code', { parts: ['body'] })
184
186
  assert.deepEqual(made.binding.table, { CODE: {} })
185
- const asset = editFigurePart(DOG, { kind: 'set-data-binding', binding: made.binding, schema: made.schema })
186
- assert.deepEqual(tableDraftOf(DOG, { name: 'RATE' }, 'ranges', { parts: ['body'] }).schema!.at(-1), { name: 'RATE', kind: 'number' })
187
+ const asset = editFigurePart(typed, { kind: 'set-data-binding', binding: made.binding })
187
188
  /* 닫힌 필드의 값 목록은 표의 키를 따라간다: 코드를 더하면 필드에도. */
188
189
  const b = asset.dataInterface!.bindings.find(x => x.id === 'emptytype')! as never
189
190
  const grown = tableWithKeys(b, [['FULL', { color: '#3b6d11' }], ['EMPTY', {}]])
@@ -194,6 +195,25 @@ test('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자
194
195
  assert.equal(legendRoleOf('Body_Status'), 'body-status')
195
196
  })
196
197
 
198
+ /*
199
+ * ADR-0166 개정 1 (2026-11-12, 튜토리얼): MCS 는 STATUS 를 숫자(4002)로 보낸다. 숫자 칸에도 코드 표를 만들고, 「그 밖의 값」은
200
+ * 표가 마지막으로 보인 모양을 그대로 둘 수 있다 -- 4004(이동 중)에 포크가 내려가지 않는다.
201
+ */
202
+ test('숫자 칸의 코드 표: 키는 숫자, 새 코드도 숫자 -- 그 밖의 값은 「그대로 둠」, 대상을 바꿔도 남는다 (ADR-0166 개정 1)', () => {
203
+ const draft = tableDraftOf(DOG, { path: 'cadence', node: fieldAt(DOG.dataInterface!.schema, 'cadence')! }, 'code', { parts: ['body'] })
204
+ assert.deepEqual(draft.binding.table, { 0: {} }, '숫자 칸의 첫 코드는 0')
205
+ assert.equal(draft.binding.lookup, 'code')
206
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: draft.binding })
207
+ assert.equal(freeNumberCodeOf([]), '0')
208
+ assert.equal(freeNumberCodeOf(['4002', '4003']), '4004')
209
+ assert.equal(freeNumberCodeOf(['-1', 'x']), '0')
210
+ const kept = tableWithKeep(tableWithKeys(draft.binding, [['4002', { color: '#3b6d11' }], ['4003', {}]]), true)
211
+ assert.deepEqual(kept.table, { 4002: { color: '#3b6d11' }, 4003: {}, default: { keep: true } })
212
+ editFigurePart(DOG, { kind: 'set-data-binding', binding: kept })
213
+ assert.deepEqual(tableWithTargets(kept, { parts: ['head'] }).table.default, { keep: true }, '대상을 바꿔도 그대로 둠은 남는다')
214
+ assert.deepEqual(tableWithKeep(kept, false).table.default, {}, '끄면 빈 모양(기본값)')
215
+ })
216
+
197
217
  test('표의 대상과 열: 부품은 색·효과, 조작값은 값 하나씩, 동작 그룹은 클립·배속·멈춤 -- 대상을 빼면 그 열도 빠진다', () => {
198
218
  const free = editFigurePart(DOG, { kind: 'remove-data-binding', id: 'mode-locomotion' })
199
219
  const draftTable = tableDraftOf(free, { path: 'mode', node: fieldAt(free.dataInterface!.schema, 'mode')! }, 'code', { parts: ['body'], inputs: ['fl-knee'], group: 'locomotion' }).binding
@@ -295,6 +315,17 @@ test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () =>
295
315
  assert.match(editor, /<figure-data-panel/)
296
316
  assert.match(editor, /\.sample=\$\{this\.dataSample\}/)
297
317
  assert.match(editor, /this\.dataSample = e\.detail\.data/)
318
+ /*
319
+ * 견본 데이터는 미리보기에서만 보인다: 편집 화면에서 「미리보기에 적용」을 켜면 그렇게 말하고, 단추 하나로 미리보기로 간다
320
+ * (튜토리얼 2026-11-12: 켜도 편집 화면이 그대로라 적용이 안 된 것처럼 보였다). 편집 화면은 기본 자세 그대로다.
321
+ */
322
+ assert.match(editor, /\?previewing=\$\{this\.middle === 'preview'\}/)
323
+ assert.match(editor, /@show-preview=\$\{\(\) => \(this\.middle = 'preview'\)\}/)
324
+ const panel = read('../client/modeller/figure-data-panel.ts')
325
+ assert.match(panel, /this\.sampling && !this\.previewing/)
326
+ assert.match(panel, /'figure\.text\.data-sample-shown-in-preview'/)
327
+ assert.match(panel, /'figure\.button\.data-sample-show-preview'/)
328
+ assert.match(panel, /new CustomEvent\('show-preview'/)
298
329
  /* 「+ 회전」은 여러 명령을 되돌리기 한 단계로: 하나가 거절되면 아무것도 바뀌지 않는다. */
299
330
  assert.match(editor, /@part-edits=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.applyRecipe\(e\.detail\.edits\)\)\}/)
300
331
  const preview = read('../client/modeller/figure-preview.ts')
@@ -134,6 +134,31 @@ test('another way of meeting works the gap out again; a number typed stays; a pr
134
134
  assert.equal(fastenChanged(r, 'lid', pressed, { mode: 'flush' }).gap, 0, 'pressed: left at 0')
135
135
  })
136
136
 
137
+ /*
138
+ * A part already fastened, aimed again through 「바꾸기」, goes where it is aimed with its fastening as it was -- the
139
+ * gap and the place across the face carried to the new face or part (tutorial 2026-11-12: a pillar copied from the front
140
+ * one and turned to the body's back face stayed in front, its gap worked out as the whole body's depth). 「현재 상태로
141
+ * 붙이기」 is a loose part's: fastening it does not move it.
142
+ */
143
+ test('a fastened part aimed again goes there with its gap and its place across the face; a loose one stays where it stands', () => {
144
+ const r = conveyor()
145
+ const own = fastenFormOf(r, 'roller')
146
+ const under = fastenChanged(r, 'roller', own, { targetFace: 'bottom' })
147
+ assert.deepEqual([under.targetFace, under.gap, under.align.x, under.alignMm.x], ['bottom', 0, 'mm', 300], 'its own gap and place, not where it stands')
148
+ const moved = applyFigureAuthoring(r.asset, fastenPlanOf(r, 'roller', under).action as never)
149
+ const [x, y] = centreOf(moved, 'roller')
150
+ assert.ok(Math.abs(x! - 300) < 1e-6, `still 300 mm across: ${x}`)
151
+ assert.ok(y! < 0, `under the frame now: ${y}`)
152
+ /* Another part: on it, as fastened before. */
153
+ const onLid = aimFasten(r, 'roller', own, { part: 'lid' })
154
+ assert.deepEqual([onLid.target, onLid.gap, onLid.alignMm.x], ['lid', 0, 300])
155
+ /* A mode changed keeps its gap too; a number typed is the author's. */
156
+ assert.equal(fastenChanged(r, 'roller', own, { mode: 'flush' }).gap, 0)
157
+ /* The loose lid is fastened where it stands, as before. */
158
+ const loose = keptGapOf(floating(), 'lid', aimFasten(floating(), 'lid', fastenFormOf(floating(), 'lid'), { part: 'plate' }))
159
+ assert.equal(loose.gap, 40)
160
+ })
161
+
137
162
  test('the target list: every other part, the one hanging from this part listed as a loop', () => {
138
163
  const r = conveyor()
139
164
  assert.deepEqual(fastenTargetsOf(r, 'frame'), [
@@ -204,6 +229,23 @@ test("pressed on the canvas: the face or the point pressed, read in that part's
204
229
  assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
205
230
  })
206
231
 
232
+ test('a part still where the palette put it goes to the centre of the face; one the author moved keeps its place across it (review 2026-11-11)', () => {
233
+ /* A thigh 85 mm left of the middle of a pelvis 880 mm up; a shin and an arm added from the palette at (0, 50, 0). */
234
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
235
+ a = applyFigureAuthoring(box(a, 'thigh', [130, 420, 140], [-85, 670, 0]), { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' }, facing: 'meet', align: { x: { mm: -85 } } } as never)
236
+ a = box(box(a, 'shin', [110, 420, 120], [0, 50, 0]), 'arm', [90, 300, 90], [0, 50, 0])
237
+ const r = revisionOf(a)
238
+ const shin = aimFasten(r, 'shin', fastenFormOf(r, 'shin'), { part: 'thigh' })
239
+ const onBottom = aimFasten(r, 'shin', shin, { targetFace: 'bottom' })
240
+ assert.deepEqual([onBottom.align, onBottom.alignMm], [{}, {}], 'on the centre of the thigh, not 85 mm off it')
241
+ const arm = aimFasten(r, 'arm', aimFasten(r, 'arm', fastenFormOf(r, 'arm'), { part: 'pelvis' }), { targetFace: 'left' })
242
+ assert.deepEqual([arm.align, arm.alignMm], [{}, {}], 'on the side, not 920 mm below it')
243
+ /* Moved by the author first, it keeps where it stands across the face. */
244
+ const moved = revisionOf(box(a, 'tag', [40, 40, 40], [-60, 500, 30]))
245
+ const kept = aimFasten(moved, 'tag', aimFasten(moved, 'tag', fastenFormOf(moved, 'tag'), { part: 'thigh' }), { targetFace: 'bottom' })
246
+ assert.deepEqual(kept.alignMm, { x: 25, z: 30 })
247
+ })
248
+
207
249
  test('a part still where the palette put it is fastened on the face; one the author moved keeps its place (tutorial 2026-11-11)', () => {
208
250
  /* A body on the floor; a mast added from the palette stands inside it, at the palette's place (0, 50, 0). */
209
251
  let a = fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane')
@@ -216,3 +258,30 @@ test('a part still where the palette put it is fastened on the face; one the aut
216
258
  const kept = keptGapOf(moved, 'mast', aimFasten(moved, 'mast', fastenFormOf(moved, 'mast'), { part: 'body' }))
217
259
  assert.equal(kept.gap, 50, 'the 50 mm its bottom stands above the body')
218
260
  })
261
+
262
+ test('aimed at a part, the form starts on a face nothing is fastened to yet (review 2026-11-11)', () => {
263
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
264
+ a = applyFigureAuthoring(box(a, 'torso', [340, 500, 200], [0, 50, 0]), { kind: 'attach', part: 'torso', to: { part: 'pelvis', face: 'top' } } as never)
265
+ a = box(a, 'thigh', [130, 420, 140], [0, 50, 0])
266
+ const r = revisionOf(a)
267
+ const thigh = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'pelvis' })
268
+ assert.equal(thigh.targetFace, 'bottom', 'across from the top, where the body stands')
269
+ assert.equal(aimFasten(r, 'torso', fastenFormOf(r, 'torso'), { part: 'pelvis' }).targetFace, 'top', 'a part keeps the face it is fastened to')
270
+ const head = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'torso' })
271
+ assert.equal(head.targetFace, 'top', 'a free top is taken as asked')
272
+ /* A shin aimed at a thigh hung under the pelvis: the thigh's top touches the pelvis, so the shin goes under it. */
273
+ let b = applyFigureAuthoring(a, { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' } } as never)
274
+ b = box(b, 'shin', [110, 420, 120], [0, 50, 0])
275
+ const rb = revisionOf(b)
276
+ assert.equal(aimFasten(rb, 'shin', fastenFormOf(rb, 'shin'), { part: 'thigh' }).targetFace, 'bottom')
277
+ /* A forearm aimed at an upper arm hung by its side from the torso: on down, under the arm (tutorial run 2026-11-12). */
278
+ let c = applyFigureAuthoring(box(a, 'upperarm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'upperarm', to: { part: 'torso', face: 'left' } } as never)
279
+ c = box(c, 'forearm', [80, 280, 80], [0, 50, 0])
280
+ const rc = revisionOf(c)
281
+ assert.equal(aimFasten(rc, 'forearm', fastenFormOf(rc, 'forearm'), { part: 'upperarm' }).targetFace, 'bottom')
282
+ /* A flat part hung by its side is not a limb: what goes on it keeps the face asked. */
283
+ let d = applyFigureAuthoring(box(a, 'shelf', [300, 40, 200], [0, 50, 0]), { kind: 'attach', part: 'shelf', to: { part: 'torso', face: 'left' } } as never)
284
+ d = box(d, 'cup', [60, 80, 60], [0, 50, 0])
285
+ const rd = revisionOf(d)
286
+ assert.equal(aimFasten(rd, 'cup', fastenFormOf(rd, 'cup'), { part: 'shelf' }).targetFace, 'top')
287
+ })
@@ -121,3 +121,16 @@ test('the face drawing shows a part bigger than the face whole when lined up wit
121
121
  assert.equal(drawingSpanOf(600, 300), 900)
122
122
  assert.equal(drawingSpanOf(undefined, 200), 600)
123
123
  })
124
+
125
+ /*
126
+ * A part far off the face's centre -- a palette box left 1500 mm up, aimed at a body's front -- is drawn whole with its
127
+ * size label: the drawing reaches it, not only the face and the part's own size (roll cradle tutorial 2026-11-12: the
128
+ * label was cut off the drawing's top and could not be pressed).
129
+ */
130
+ test('the face drawing reaches a part standing far off the face, its far edge inside', () => {
131
+ const span = drawingSpanOf(1200, 300, 1500)
132
+ assert.ok(span / 2 >= 1500 + 300 / 2, `span ${span}`)
133
+ assert.equal(drawingSpanOf(1200, 300, -1500), span, 'either side alike')
134
+ /* Near the centre it keeps the room it had. */
135
+ assert.equal(drawingSpanOf(600, 300, 100), 900)
136
+ })
@@ -5,7 +5,7 @@ import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
 
7
7
  import { placeholderType } from '@hatiolab/figure-model'
8
- import { firstSaveRefusalOf, releasableType, typeOf } from '../client/modeller/first-save.ts'
8
+ import { firstSaveRefusalOf, releasableType, typeOf, typingTypeOf } from '../client/modeller/first-save.ts'
9
9
  import { createFigureProductSession, type FigureProductStore } from '../client/modeller/product-session.ts'
10
10
 
11
11
  test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이픈 하나', () => {
@@ -14,6 +14,16 @@ test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이
14
14
  assert.equal(typeOf('-Roller Conveyor-'), 'roller-conveyor')
15
15
  })
16
16
 
17
+ test('하이픈을 칠 수 있다 — 한 글자씩 쳐도 끝의 하이픈이 남고, 저장할 때 다듬는다', () => {
18
+ let typed = ''
19
+ for (const key of 'fork-agv-tutorial') typed = typingTypeOf(typed + key)
20
+ assert.equal(typed, 'fork-agv-tutorial')
21
+ let trailing = ''
22
+ for (const key of 'Fork AGV ') trailing = typingTypeOf(trailing + key)
23
+ assert.equal(trailing, 'fork-agv-')
24
+ assert.equal(typeOf(trailing), 'fork-agv')
25
+ })
26
+
17
27
  test('발행할 수 없는 이름은 미리 말한다 — 임시 이름 · 형식 · 길이', () => {
18
28
  assert.equal(releasableType('agv-lifter'), true)
19
29
  assert.equal(releasableType(placeholderType()), false, '서버가 붙이는 임시 이름')
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Named points a turn's axis may pass (`pivot.ts`): worked out from the part as it rests, they keep the named point
3
+ * still as the joint turns -- whatever the face, the alignment, the slider.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
9
+ import type { FigureAsset } from '@hatiolab/figure-model'
10
+
11
+ import { motionJoint } from '../client/modeller/links.ts'
12
+ import { pivotPointOf, pivotPointsOf } from '../client/modeller/pivot.ts'
13
+ import { revisionOf } from './revision-of.ts'
14
+
15
+ const box = (a: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(a, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
16
+ const turn = (through = {}) => ({ kind: 'turn', axis: 'x', id: 'sh', min: -90, max: 90, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0, through }) as never
17
+
18
+ /* An arm of 300 mm hung by its side from a torso, level with its top -- the lesson's upper arm -- or under it. */
19
+ function arm(face: 'left' | 'bottom', align?: object) {
20
+ const a = box(createFigureAsset('H'), 'torso', [340, 500, 200], [0, 1000, 0])
21
+ return applyFigureAuthoring(box(a, 'arm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'arm', to: { part: 'torso', face }, ...(align ? { align } : {}) } as never)
22
+ }
23
+ const endsAt = (a: FigureAsset, through: object, value: number) => {
24
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
25
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(through), 'arm') } as never)
26
+ const g = (revisionOf(b, { sh: value }).geometry as Array<{ id: string; pose: { r: number[][]; t: number[] } }>).find(p => p.id === 'arm')!.pose
27
+ /* The arm's top and bottom end centres in the figure's frame. */
28
+ return [150, -150].map(y => [0, 1, 2].map(i => g.r[i]![1]! * y + g.t[i]!))
29
+ }
30
+ const near = (a: number[], b: number[]) => a.every((v, i) => Math.abs(v - b[i]!) < 0.5)
31
+
32
+ test('a named pivot stays where it is as the joint turns: top, middle, bottom of a side-hung or an under-hung arm (tutorial 2026-11-12)', () => {
33
+ for (const [face, align] of [['left', { y: 'max' }], ['left', undefined], ['bottom', undefined]] as const) {
34
+ const a = arm(face, align)
35
+ const points = pivotPointsOf(revisionOf(a), 'arm', 'x')!
36
+ assert.ok(points, `${face}: points`)
37
+ const [top0, bottom0] = endsAt(a, points.top, 0)
38
+ assert.ok(near(endsAt(a, points.top, 70)[0]!, top0!), `${face} ${JSON.stringify(align)}: the top end stays`)
39
+ assert.ok(near(endsAt(a, points.bottom, 70)[1]!, bottom0!), `${face} ${JSON.stringify(align)}: the bottom end stays`)
40
+ assert.ok(!near(endsAt(a, points.middle, 70)[0]!, top0!), `${face}: about the middle the top end moves`)
41
+ }
42
+ })
43
+
44
+ test('the lesson upper arm, level with the torso top, already turns about its top end: no number is needed', () => {
45
+ const points = pivotPointsOf(revisionOf(arm('left', { y: 'max' })), 'arm', 'x')!
46
+ assert.deepEqual(points, { top: {}, middle: { y: -150 }, bottom: { y: -300 } })
47
+ assert.equal(pivotPointOf(points, {}, 'x'), 'top')
48
+ assert.equal(pivotPointOf(points, { y: 120 }, 'x'), undefined, 'Y 120 put the axis above the shoulder')
49
+ })
50
+
51
+ test('a part fastened to nothing, or a slider left turned, has no wrong points', () => {
52
+ assert.equal(pivotPointsOf(revisionOf(box(createFigureAsset('H'), 'solo', [10, 10, 10])), 'solo'), null)
53
+ const a = arm('bottom')
54
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
55
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(), 'arm') } as never)
56
+ assert.deepEqual(pivotPointsOf(revisionOf(b, { sh: 60 }), 'arm'), pivotPointsOf(revisionOf(b), 'arm'))
57
+ })