@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.
- package/client/modeller/canvas.ts +4 -2
- package/client/modeller/clips.ts +74 -4
- package/client/modeller/data-interface.ts +30 -18
- package/client/modeller/editor-styles.ts +17 -6
- package/client/modeller/editor.ts +2 -0
- package/client/modeller/fasten-form.ts +81 -6
- package/client/modeller/fasten-geometry.ts +5 -3
- package/client/modeller/figure-cavity-drawing.ts +1 -3
- package/client/modeller/figure-clips.ts +101 -8
- package/client/modeller/figure-data-panel.ts +384 -200
- package/client/modeller/figure-fasten-dialog.ts +41 -30
- package/client/modeller/figure-fasten-drawing.ts +24 -14
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +109 -37
- package/client/modeller/figure-parts.ts +7 -11
- package/client/modeller/figure-preview.ts +299 -79
- package/client/modeller/figure-size-editor.ts +7 -2
- package/client/modeller/first-save.ts +8 -0
- package/client/modeller/focus-on-render.ts +4 -0
- package/client/modeller/pivot.ts +76 -0
- package/client/modeller/size-response.ts +14 -0
- package/client/modeller/stage-model.ts +50 -6
- package/dist-client/modeller/canvas.d.ts +1 -0
- package/dist-client/modeller/canvas.js +3 -2
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +17 -3
- package/dist-client/modeller/clips.js +69 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/data-interface.d.ts +11 -8
- package/dist-client/modeller/data-interface.js +27 -17
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +17 -6
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.js +2 -0
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +13 -0
- package/dist-client/modeller/fasten-form.js +72 -7
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +3 -2
- package/dist-client/modeller/fasten-geometry.js +5 -3
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-cavity-drawing.js +1 -3
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +9 -0
- package/dist-client/modeller/figure-clips.js +104 -8
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +10 -19
- package/dist-client/modeller/figure-data-panel.js +382 -190
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.js +39 -24
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.js +24 -14
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-first-save.js +4 -3
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -0
- package/dist-client/modeller/figure-links.js +120 -51
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +7 -11
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +13 -0
- package/dist-client/modeller/figure-preview.js +296 -80
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.js +7 -3
- package/dist-client/modeller/figure-size-editor.js.map +1 -1
- package/dist-client/modeller/first-save.d.ts +5 -0
- package/dist-client/modeller/first-save.js +7 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/focus-on-render.d.ts +2 -0
- package/dist-client/modeller/focus-on-render.js +4 -0
- package/dist-client/modeller/focus-on-render.js.map +1 -0
- package/dist-client/modeller/pivot.d.ts +12 -0
- package/dist-client/modeller/pivot.js +59 -0
- package/dist-client/modeller/pivot.js.map +1 -0
- package/dist-client/modeller/size-response.d.ts +6 -0
- package/dist-client/modeller/size-response.js +14 -0
- package/dist-client/modeller/size-response.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +17 -3
- package/dist-client/modeller/stage-model.js +44 -6
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/test/browser/editing-page.js +1 -1
- package/test/browser/editing.check.mjs +6 -5
- package/test/browser/links.check.mjs +30 -0
- package/test/browser/modeller.check.mjs +2 -2
- package/test/browser/parts-page.js +4 -0
- package/test/browser/parts.check.mjs +16 -10
- package/test/browser/screen-page.js +12 -1
- package/test/browser/screen.check.mjs +142 -11
- package/test/browser/section.check.mjs +21 -0
- package/test/browser/serve-response.mjs +5 -2
- package/test/browser/side-panel-sweep.check.mjs +171 -10
- package/test/clips.test.ts +49 -4
- package/test/data-motion.test.ts +39 -8
- package/test/fasten-form.test.ts +69 -0
- package/test/fasten-geometry.test.ts +13 -0
- package/test/first-save.test.ts +11 -1
- package/test/pivot.test.ts +57 -0
- package/test/size-response.test.ts +10 -2
- package/test/stage-model.test.ts +23 -1
- package/translations/en.json +31 -11
- package/translations/es.json +30 -10
- package/translations/ja.json +30 -10
- package/translations/ko.json +32 -12
- package/translations/ms.json +30 -10
- package/translations/zh.json +30 -10
- package/tutorial/humanoid/README.md +22 -0
- package/tutorial/humanoid/chapters.mjs +247 -0
- package/tutorial/humanoid/encode.mjs +26 -0
- package/tutorial/humanoid/lib.mjs +197 -0
- 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
|
-
|
|
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 ===
|
|
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
|
|
166
|
-
|
|
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:
|
|
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:
|
|
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
|
})
|
package/test/clips.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/test/data-motion.test.ts
CHANGED
|
@@ -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('표 초안: 닫힌 칸은 값마다 줄, 열린 칸은 줄 없이, 숫자는 한 구간
|
|
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
|
|
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
|
|
183
|
-
|
|
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(
|
|
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')
|
package/test/fasten-form.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/test/first-save.test.ts
CHANGED
|
@@ -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
|
+
})
|