@things-factory/figure-ui 10.1.151 → 10.1.152

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 (45) hide show
  1. package/client/modeller/editor.ts +15 -23
  2. package/client/modeller/fasten-geometry.ts +32 -0
  3. package/client/modeller/figure-links.ts +32 -14
  4. package/client/modeller/mesh-import.ts +0 -10
  5. package/client/modeller/refusals.ts +14 -1
  6. package/dist-client/modeller/editor.d.ts +0 -6
  7. package/dist-client/modeller/editor.js +16 -24
  8. package/dist-client/modeller/editor.js.map +1 -1
  9. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  10. package/dist-client/modeller/fasten-geometry.js +29 -0
  11. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  12. package/dist-client/modeller/figure-links.d.ts +9 -2
  13. package/dist-client/modeller/figure-links.js +35 -14
  14. package/dist-client/modeller/figure-links.js.map +1 -1
  15. package/dist-client/modeller/mesh-import.d.ts +0 -2
  16. package/dist-client/modeller/mesh-import.js +0 -11
  17. package/dist-client/modeller/mesh-import.js.map +1 -1
  18. package/dist-client/modeller/product-session.d.ts +4 -28
  19. package/dist-client/modeller/refusals.d.ts +2 -1
  20. package/dist-client/modeller/refusals.js +17 -1
  21. package/dist-client/modeller/refusals.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +4 -4
  24. package/test/browser/build.mjs +7 -2
  25. package/test/browser/editing.check.mjs +14 -0
  26. package/test/browser/fix-alignment.check.mjs +101 -0
  27. package/test/browser/links.check.mjs +34 -5
  28. package/test/browser/mesh-select.check.mjs +91 -0
  29. package/test/browser/modeller-page.js +36 -0
  30. package/test/browser/modeller-revision.check.mjs +18 -6
  31. package/test/browser/part-ids.check.mjs +24 -6
  32. package/test/browser/parts.check.mjs +39 -2
  33. package/test/browser/row-preview.check.mjs +81 -0
  34. package/test/browser/screen.check.mjs +2 -1
  35. package/test/fields.test.ts +5 -9
  36. package/test/fix-alignment.test.ts +32 -0
  37. package/test/mesh-import.test.ts +1 -8
  38. package/test/parts-wiring.test.ts +1 -5
  39. package/test/refusals.test.ts +28 -0
  40. package/translations/en.json +8 -0
  41. package/translations/es.json +8 -0
  42. package/translations/ja.json +8 -0
  43. package/translations/ko.json +8 -0
  44. package/translations/ms.json +8 -0
  45. package/translations/zh.json +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.151",
3
+ "version": "10.1.152",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
21
- "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs",
21
+ "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.6.0",
30
+ "@hatiolab/figure-model": "^0.6.2",
31
31
  "@hatiolab/things-scene": "^10.1.83",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
@@ -45,5 +45,5 @@
45
45
  "@things-factory/figure-service": "^10.1.151",
46
46
  "three": "^0.185.1"
47
47
  },
48
- "gitHead": "495d06ef9ecf4d19fc9bf45ebc3cff4fdfefca53"
48
+ "gitHead": "67dd923b0d431d726b7f60866bb005a3660852b3"
49
49
  }
@@ -7,7 +7,7 @@
7
7
  * (its browser bundle, `things-scene.mjs`).
8
8
  */
9
9
  import { execFileSync } from 'node:child_process'
10
- import { mkdtempSync, rmSync, existsSync } from 'node:fs'
10
+ import { mkdtempSync, realpathSync, rmSync, existsSync } from 'node:fs'
11
11
  import { tmpdir } from 'node:os'
12
12
  import { dirname, join, resolve } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
@@ -41,7 +41,12 @@ for (const signal of ['SIGINT', 'SIGTERM'])
41
41
  * assistant dock needs the whole app's tool bar), a stand-in that records what the product asked of it.
42
42
  */
43
43
  export async function buildPage(page = 'modeller-page.js', { standIns = {} } = {}) {
44
- const out = mkdtempSync(join(tmpdir(), 'figure-ui-browser-' + page.replace(/\W+/g, '-') + '-'))
44
+ /*
45
+ The real path: on macOS the temp dir is a link (/var -> /private/var). A module named both through
46
+ `figure-ui-client/...` and by a relative import was bundled twice, once under each path, and a page that imports one
47
+ both ways (product-page.js: the modeller page and the history panel) defined its element twice and never came up.
48
+ */
49
+ const out = mkdtempSync(join(realpathSync(tmpdir()), 'figure-ui-browser-' + page.replace(/\W+/g, '-') + '-'))
45
50
  made.push(out)
46
51
  const emitted = join(out, 'client')
47
52
 
@@ -218,6 +218,20 @@ describe('a part a joint places is not moved by the bar, nor put on the plane',
218
218
  })
219
219
  })
220
220
 
221
+ describe('a part a joint places, dragged with the move handle all the same (the six-axis arm, a saved asset)', () => {
222
+ before(() => mount('six-axis'))
223
+
224
+ test('nothing written, and the refusal says the joint places it', async () => {
225
+ await pick('upper-arm')
226
+ const start = await revision()
227
+ await dragHandle('upper-arm', 'translate', 'X', 80)
228
+ await until(async () => (await state()).error !== '', 'the refusal said')
229
+ assert.equal(await revision(), start, 'nothing written')
230
+ /* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key. */
231
+ assert.equal((await state()).error, 'figure.text.gizmo-joint-places')
232
+ })
233
+ })
234
+
221
235
  describe('put on the plane by the figure\'s placement: once, not fastened (designer 2026-09-27)', () => {
222
236
  test('a box 500 mm above the floor is put on it by its bottom, in one step', async () => {
223
237
  await mount('box', 'raised')
@@ -0,0 +1,101 @@
1
+ /*
2
+ * 정렬·간격 고치기, in a real browser: a part fastened past the edge of the face it stands on is moved inside by the
3
+ * button, as one step; when nothing of the part itself is out, the button says why instead of doing nothing (on :3300,
4
+ * 2026-10-11, it only scrolled to the drawing, which was already in view).
5
+ *
6
+ * Run: node --test test/browser/fix-alignment.check.mjs
7
+ */
8
+ import { test, describe, before, after } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
12
+
13
+ import { buildPage } from './build.mjs'
14
+ import { openPage } from './serve.mjs'
15
+
16
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
17
+
18
+ let opened
19
+ before(async () => {
20
+ opened = await openPage(await buildPage('places-mesh-page.js'))
21
+ })
22
+ after(async () => {
23
+ await opened?.close()
24
+ })
25
+
26
+ const page = () => opened.page
27
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
28
+ const state = () => read(() => probe.state())
29
+
30
+ async function until(fn, args, what, ms = 8000) {
31
+ const deadline = Date.now() + ms
32
+ for (;;) {
33
+ if (await read(fn, ...args)) return
34
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
35
+ await sleep(50)
36
+ }
37
+ }
38
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
39
+ async function click(selector, options = {}, what = selector) {
40
+ const element = await page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
41
+ assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
42
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
43
+ await element.click()
44
+ await settled()
45
+ }
46
+ async function pick(part) {
47
+ const at = await read(id => probe.row(id), part)
48
+ assert.ok(at, `the row of ${part}`)
49
+ await page().mouse.click(at.x, at.y)
50
+ await until(id => probe.state().selected === id, [part], `${part} selected`)
51
+ }
52
+ const jointOf = part => read(p => probe.source().joints.find(j => j.body1.part === p), part)
53
+ const words = () => read(() => [...(findDeep('div[overhang]')?.querySelectorAll('p') ?? [])].map(p => p.textContent.trim()))
54
+
55
+ /* A 400 × 1000 deck; a 100 mm block on it, fastened 1000 mm off its centre along X -- past its low edge. */
56
+ function figure(withChild = false) {
57
+ let a = addFigureBox(createFigureAsset('Deck'), { id: 'deck', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 25, z: 0 } })
58
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'deck', face: 'bottom', to: { plane: 'mounting-plane' } })
59
+ a = addFigureBox(a, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 100, z: 0 } })
60
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'deck', face: 'top' }, ...(withChild ? {} : { align: { x: { mm: -1000 } } }) })
61
+ if (!withChild) return a
62
+ /* The block inside; a cap on it, past the block's edge. */
63
+ a = addFigureBox(a, { id: 'cap', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 170, z: 0 } })
64
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'block', face: 'top' }, align: { x: { mm: 300 } } })
65
+ }
66
+
67
+ describe('정렬·간격 고치기 moves a part inside the face it is fastened on', () => {
68
+ before(async () => {
69
+ await read(text => harness.mount('given', text), serializeFigureAsset(figure()))
70
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
71
+ await pick('block')
72
+ })
73
+
74
+ test('out past the low edge: said, then moved to that edge as one step, and no longer said', async () => {
75
+ await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
76
+ assert.ok((await words()).length > 0, 'the overhang is said')
77
+ const start = await state()
78
+ await click('button[fix-alignment]')
79
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
80
+ assert.deepEqual((await jointOf('block')).origin.align?.x, 'min')
81
+ await until(() => !findDeep('div[overhang]'), [], 'nothing said of an overhang')
82
+ assert.equal((await state()).error, '')
83
+ })
84
+ })
85
+
86
+ describe('when nothing of the part is out, the button says why', () => {
87
+ before(async () => {
88
+ await read(text => harness.mount('given', text), serializeFigureAsset(figure(true)))
89
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
90
+ await pick('block')
91
+ })
92
+
93
+ test('what is out is the cap fastened on it: said under the button, nothing written', async () => {
94
+ await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
95
+ const start = await state()
96
+ await click('button[fix-alignment]')
97
+ await until(() => !!findDeep('p[fix-note]'), [], 'the note')
98
+ assert.match(await read(() => findDeep('p[fix-note]').textContent.trim()), /^figure\.text\.fix-alignment-inside/)
99
+ assert.equal((await state()).revision, start.revision)
100
+ })
101
+ })
@@ -113,7 +113,24 @@ async function choose(label, value, selector = 'select') {
113
113
  async function type(field, text) {
114
114
  assert.ok(await field.evaluate(i => !!i), 'the field is on the page')
115
115
  await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
116
+ /*
117
+ Pressed where it stands once the panel has stopped moving: what a fold opening or a preview's words bring is drawn
118
+ after the click that brought it, and a press aimed before that lands on whatever moved under it.
119
+ */
120
+ const where = () => field.evaluate(i => JSON.stringify(i.getBoundingClientRect()))
121
+ for (let last = await where(), i = 0; i < 40; i++) {
122
+ await sleep(100)
123
+ const now = await where()
124
+ if (now === last) break
125
+ last = now
126
+ }
116
127
  await field.click({ clickCount: 3 })
128
+ const focused = await field.evaluate(i => {
129
+ let at = document.activeElement
130
+ while (at?.shadowRoot?.activeElement) at = at.shadowRoot.activeElement
131
+ return at === i
132
+ })
133
+ assert.ok(focused, 'the press lands in the field')
117
134
  await page().keyboard.type(text)
118
135
  await page().keyboard.press('Tab')
119
136
  await settled()
@@ -369,8 +386,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
369
386
  test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
370
387
  const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
371
388
  await choose('figure.label.target-part', 'frame')
372
- /* The frame's faces are offered once the drawing has the frame: a press before that is not a choice. */
373
- await until(() => look.face('front')?.pickable, [], 'the front face offered')
389
+ /*
390
+ The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
391
+ aimed once the form has drawn what the choice brings -- the leaf where it would go, and the words above the drawing
392
+ that move it down -- or it lands where the face was.
393
+ */
394
+ await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
395
+ await settled()
396
+ await sleep(200)
374
397
  await click(await at('path[face="front"]'), 'the front face in the drawing')
375
398
  await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
376
399
  const start = await state()
@@ -395,11 +418,13 @@ describe('past the edge of the face a part is fastened to', () => {
395
418
  })
396
419
  const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
397
420
 
398
- test('past it: said, and "fix alignment" puts the focus on the face in the drawing', async () => {
421
+ test('past it: said, and "fix alignment" moves it inside the face as one step', async () => {
399
422
  await pick('out')
400
423
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the edge')
424
+ const start = await state()
401
425
  await click(await at('div[overhang] button', 'figure.button.fix-alignment'), 'fix alignment')
402
- await until(() => look.focusedIn('figure-fasten-drawing', 'div[where]'), [], 'the face in the drawing focused')
426
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
427
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'no longer past the edge')
403
428
  })
404
429
 
405
430
  test('turned, it may be past it: said so', async () => {
@@ -407,9 +432,13 @@ describe('past the edge of the face a part is fastened to', () => {
407
432
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
408
433
  })
409
434
 
410
- test('on a round face it is not known: said so', async () => {
435
+ test('on a round face it is not known: said so, and "fix alignment" says why it cannot', async () => {
411
436
  await pick('cap')
412
437
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
438
+ const start = await state()
439
+ await click(await at('div[overhang] button', 'figure.button.fix-alignment'), 'fix alignment')
440
+ await until(() => look.all('p[fix-note]', 'figure.text.fix-alignment-unmeasured').length === 1, [], 'why it cannot')
441
+ assert.equal((await state()).revision, start.revision, 'nothing written')
413
442
  })
414
443
 
415
444
  test('wider than the face: said in the relation with which side, not as a warning', async () => {
@@ -0,0 +1,91 @@
1
+ /*
2
+ * A GLB imported on the modeller page as the shell mounts it (product-page.js), with the mouse as a person uses it: the
3
+ * palette's file chooser, the question drawn as the layout draws popups, a choice clicked in it. The part made is the
4
+ * one selected (on :3300, 2026-10-11, the part selected before the import stayed selected; the editor alone said
5
+ * otherwise, so this follows the page's own path).
6
+ *
7
+ * Run: node --test test/browser/mesh-select.check.mjs
8
+ */
9
+ import { test, describe, before, after } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { writeFileSync, mkdtempSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
16
+
17
+ import { buildPage } from './build.mjs'
18
+ import { openPage } from './serve.mjs'
19
+
20
+ const STAND_INS = {
21
+ '@things-factory/ai-assistant/dist-client/index.js': 'assistant-stand-in.js',
22
+ '@things-factory/ai-assistant/dist-client/components/assistant-dock.js': 'assistant-stand-in.js'
23
+ }
24
+
25
+ let opened
26
+ before(async () => {
27
+ opened = await openPage(await buildPage('product-page.js', { standIns: STAND_INS }))
28
+ })
29
+ after(async () => {
30
+ await opened?.close()
31
+ })
32
+
33
+ const page = () => opened.page
34
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
35
+ async function until(fn, label, ...args) {
36
+ try {
37
+ return await (await page().waitForFunction(fn, { timeout: 15000, polling: 50 }, ...args)).jsonValue()
38
+ } catch (error) {
39
+ throw Error(`waited in vain: ${label} (${error.message})`)
40
+ }
41
+ }
42
+ const editor = () => read(() => ({ selected: product.deep('figure-editor')[0]?.selected, revision: product.view()?.revision }))
43
+ /** The page point of the first element the selector finds, scrolled into view. */
44
+ const pointOf = selector =>
45
+ read(s => {
46
+ const e = product.deep(s)[0]
47
+ if (!e) return null
48
+ e.scrollIntoView({ block: 'center' })
49
+ const r = e.getBoundingClientRect()
50
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
51
+ }, selector)
52
+ async function clickAt(selector, label = selector) {
53
+ await until(s => !!product.deep(s)[0], label, selector)
54
+ const at = await pointOf(selector)
55
+ await page().mouse.click(at.x, at.y)
56
+ }
57
+
58
+ const HASH = 'c'.repeat(64)
59
+ const bounds = (x0, y0, z0, x1, y1, z1) => ({ min: { x: x0, y: y0, z: z0 }, max: { x: x1, y: y1, z: z1 } })
60
+ const node = (index, name, b, triangles) => ({ index, name, parent: index ? 0 : null, bounds: b, triangles, primitives: 1, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } })
61
+ const girder = bounds(-1000, -150, -200, 1000, 150, 200)
62
+ const UPLOAD = { hash: HASH, summary: { bounds: girder, triangles: 24, primitives: 2, unit: 'm', nodes: [node(0, 'Girder', girder, 24), node(1, 'Hoist', bounds(-150, -200, -150, 150, 200, 150), 12)] } }
63
+
64
+ /* A wide slab filling the middle of the view, selected: what lies under the question when it is answered. */
65
+ const slab = () => serializeFigureAsset(addFigureBox(createFigureAsset('Slab'), { id: 'slab', size: { x: 4000, y: 100, z: 4000 }, position: { x: 0, y: 50, z: 0 } }))
66
+
67
+ describe('a GLB imported on the modeller page: the part made is the one selected', () => {
68
+ before(async () => {
69
+ await page().waitForFunction(() => !!window.product, { timeout: 30000 })
70
+ await read(s => product.seed({ id: 'f', type: 'slab', source: s }), slab())
71
+ await read(u => product.answer('uploadFigureMesh', () => u), UPLOAD)
72
+ await read(() => product.open('f'))
73
+ await until(() => { const v = product.view(); return v && v.context && !product.page().session.read().loading }, 'the figure open')
74
+ await clickAt('figure-parts >>> span[label][title="slab"]', 'the slab\'s row')
75
+ await until(() => product.deep('figure-editor')[0]?.selected === 'slab', 'the slab selected')
76
+ })
77
+
78
+ test('the whole file chosen in the question: added as one step, and selected', async () => {
79
+ const file = join(mkdtempSync(join(tmpdir(), 'figure-glb-')), 'crane.glb')
80
+ writeFileSync(file, Buffer.from('glTF'))
81
+ const start = await editor()
82
+ const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), clickAt('figure-parts >>> button[shape][title="figure.text.import-mesh-hint"]', 'the import button')])
83
+ await chooser.accept([file])
84
+ await until(() => !!product.deep('figure-mesh-choice')[0], 'the question')
85
+ await clickAt('figure-mesh-choice >>> li button', 'the whole file')
86
+ await until(r => product.view().revision === r + 1, 'one step', start.revision)
87
+ /* Read a second later too: anything that takes the selection back after the step would have done so by then. */
88
+ await new Promise(done => setTimeout(done, 1000))
89
+ assert.equal((await editor()).selected, 'crane', 'the part made is the one selected')
90
+ })
91
+ })
@@ -8,6 +8,30 @@ import { budgetOf, figureReportOf } from 'figure-ui-client/modeller/maturity.js'
8
8
  import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
9
9
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, figureWork, robotArmFigure, serializeFigureAsset } from '@hatiolab/figure-model'
10
10
 
11
+ /*
12
+ The work the modeller puts off until the screen rests (a timer of 100 ms or more: 150 ms for a pick's previews, 200 ms
13
+ for the verdict) can be held while a check counts what an event and its render did, and let run after (holdRest,
14
+ releaseRest). The count then does not lean on time: on a busy machine the put-off work ran before the check read the
15
+ count, and a pick counted 3 compiles and 8 evaluations (modeller-revision, 2026-10-11, the full test:browser only).
16
+ Shorter timers run as ever, so work moved off the event into one is still counted as the event's.
17
+ */
18
+ const REST_MS = 100
19
+ const setTimer = window.setTimeout.bind(window)
20
+ const clearTimer = window.clearTimeout.bind(window)
21
+ let holding = false
22
+ let held = []
23
+ let heldIds = 0
24
+ window.setTimeout = (callback, ms, ...args) => {
25
+ if (!holding || typeof callback !== 'function' || !(Number(ms) >= REST_MS)) return setTimer(callback, ms, ...args)
26
+ const id = -++heldIds
27
+ held.push({ id, callback, ms, args })
28
+ return id
29
+ }
30
+ window.clearTimeout = id => {
31
+ if (typeof id === 'number' && id < 0) held = held.filter(h => h.id !== id)
32
+ else clearTimer(id)
33
+ }
34
+
11
35
  const PALETTE = {
12
36
  'palette.dark': '#2b2b2b',
13
37
  'palette.neutral': '#9a9a9a',
@@ -248,6 +272,18 @@ window.probe = {
248
272
  work() {
249
273
  return { ...figureWork }
250
274
  },
275
+ /** From now the work put off until the screen rests waits (see REST_MS). */
276
+ holdRest() {
277
+ holding = true
278
+ },
279
+ /** The work held runs, each after its own delay from now; how many were held. */
280
+ releaseRest() {
281
+ holding = false
282
+ const due = held
283
+ held = []
284
+ for (const h of due) setTimer(h.callback, h.ms, ...h.args)
285
+ return due.length
286
+ },
251
287
  /**
252
288
  * The two middles -- the edit canvas and the preview -- marked, so a later look says whether each is the same element
253
289
  * with the same scene, or was made again.
@@ -16,6 +16,21 @@ import { openPage } from './serve.mjs'
16
16
  const sleep = ms => new Promise(done => setTimeout(done, ms))
17
17
  /* The event handled and the render it asked for done -- not the work told to wait until the screen rests (150 ms). */
18
18
  const drawn = () => page().evaluate(() => document.querySelector('figure-editor').updateComplete.then(() => probe.work()))
19
+ /*
20
+ What an event and its render cost, counted with the work put off until the screen rests held (probe.holdRest), so the
21
+ count does not lean on time; then that work is let run, and what it cost is the screen at rest's (2026-10-11: on a
22
+ busy machine it ran before the count was read, and a pick counted 3 compiles and 8 evaluations).
23
+ */
24
+ async function counted(event) {
25
+ const before = await work()
26
+ await read(() => probe.holdRest())
27
+ try {
28
+ await event()
29
+ return { before, spent: since(before, await drawn()) }
30
+ } finally {
31
+ await read(() => probe.releaseRest())
32
+ }
33
+ }
19
34
 
20
35
  let opened
21
36
  before(async () => {
@@ -60,9 +75,8 @@ describe('work counted on the robot arm', () => {
60
75
  /* The motion tab is drawn out of sight with the rest: the selected part's motion is evaluated at its two ends. */
61
76
  test('picking a part on the canvas compiles nothing; it evaluates only the two ends of the part\'s motion', async () => {
62
77
  const part = 'upper-arm'
63
- const before = await work()
64
- await click(await read(id => probe.partOnPage(id), part))
65
- const spent = since(before, await drawn())
78
+ const point = await read(id => probe.partOnPage(id), part)
79
+ const { before, spent } = await counted(() => click(point))
66
80
  assert.equal((await read(() => probe.state())).selected, part)
67
81
  assert.deepEqual(spent, { compiles: 0, evaluations: 2 })
68
82
  /*
@@ -89,9 +103,7 @@ describe('work counted on the robot arm', () => {
89
103
  const revision = (await read(() => probe.state())).revision
90
104
  await page().mouse.click(field.x, field.y, { clickCount: 3 })
91
105
  await page().keyboard.type(String(field.value + 10))
92
- const before = await work()
93
- await page().keyboard.press('Enter')
94
- const spent = since(before, await drawn())
106
+ const { spent } = await counted(() => page().keyboard.press('Enter'))
95
107
  assert.equal((await read(() => probe.state())).revision, revision + 1, `${field.label} is one step`)
96
108
  assert.ok(spent.compiles <= 2 && spent.evaluations <= 5, JSON.stringify(spent))
97
109
  })
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  * A new part's id, in a real browser, on a figure with a row: the palette's box, a copy from the parts list. A row's
3
3
  * copies are drawn under ids of their own; the part made into the row still holds its id, and the next part is not
4
- * offered it (on :3300, 2026-10-11, a box added after a row was refused with DUPLICATE_PART).
4
+ * offered it (on :3300, 2026-10-11, a box added after a row was refused with DUPLICATE_PART). The id is figure-model's
5
+ * to choose (figurePartIdFor): read here as whatever new part there is.
5
6
  *
6
7
  * Run: node --test test/browser/part-ids.check.mjs
7
8
  */
@@ -52,7 +53,9 @@ function rowed() {
52
53
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'box-1', face: 'bottom', to: { plane: 'mounting-plane' } })
53
54
  a = addFigureBox(a, { id: 'box-2', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 150, z: 0 } })
54
55
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'box-2', face: 'bottom', to: { part: 'box-1', face: 'top' } })
55
- return applyFigureAuthoring(a, { kind: 'make-repeat', part: 'box-2', axis: 'x', pitch: 300 })
56
+ a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'box-2', axis: 'x', pitch: 300 })
57
+ /* A part off to one side, free: its mirrored copy lands on the other side. */
58
+ return addFigureBox(a, { id: 'side', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
56
59
  }
57
60
 
58
61
  describe('a new part on a figure with a row takes an id no part holds', () => {
@@ -62,14 +65,17 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
62
65
  assert.equal((await read(() => probe.ids())).includes('box-2'), false, 'the row is drawn under its copies\' ids')
63
66
  })
64
67
 
65
- test('a box from the palette: added as box-3, one step, selected', async () => {
68
+ test('a box from the palette: added under an id no part holds, one step, selected', async () => {
66
69
  const start = await state()
67
70
  await click('button[shape]', { has: 'figure.label.primitive-cube' }, 'the palette\'s box')
68
- await until(() => probe.state().selected === 'box-3', [], 'box-3 selected')
71
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
69
72
  const after = await state()
70
73
  assert.equal(after.error, '')
71
- assert.equal(after.revision, start.revision + 1)
72
- assert.deepEqual(await parts(), ['box-1', 'box-2', 'box-3'])
74
+ const now = await parts()
75
+ assert.equal(now.length, 4)
76
+ const added = now.find(id => !['box-1', 'box-2', 'side'].includes(id))
77
+ assert.ok(added, now.join())
78
+ assert.equal(after.selected, added)
73
79
  })
74
80
 
75
81
  test('the row\'s part copied from the list: the copy is a part, and it is what is selected', async () => {
@@ -83,4 +89,16 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
83
89
  assert.equal((await state()).selected, copy)
84
90
  assert.equal((await state()).error, '')
85
91
  })
92
+
93
+ test('a part mirrored: the copy, on the other side, is what is selected', async () => {
94
+ const at = await read(() => probe.row('side'))
95
+ await page().mouse.click(at.x, at.y)
96
+ await until(() => probe.state().selected === 'side', [], 'side selected')
97
+ const before = await parts()
98
+ await click('button', { text: 'figure.button.mirror-x' }, 'mirror across X')
99
+ await until(n => probe.source().appearance.length === n, [before.length + 1], 'the copy')
100
+ const copy = (await parts()).find(id => !before.includes(id))
101
+ assert.equal((await state()).selected, copy)
102
+ assert.equal(Math.round((await read(id => probe.body(id), copy)).t[0]), -900, 'on the other side')
103
+ })
86
104
  })
@@ -202,6 +202,43 @@ describe('a removal is told: what it let go of as a notice, a refusal as an erro
202
202
  })
203
203
  })
204
204
 
205
+ describe('a part renamed in its name field: one step, still selected; a name refused is said why', () => {
206
+ before(() => mount('box', 'three'))
207
+ const name = () => read(() => editing.fieldValue('figure.label.name'))
208
+ async function rename(to) {
209
+ await tab('properties')
210
+ const at = await read(() => probe.labelled('figure.label.name'))
211
+ assert.ok(at, 'the name field')
212
+ await page().mouse.click(at.x, at.y, { clickCount: 3 })
213
+ await page().keyboard.type(to)
214
+ await page().keyboard.press('Enter')
215
+ await sleep(400)
216
+ }
217
+
218
+ test('renamed: the list has the new name, it stays selected, in one step', async () => {
219
+ await pick('a')
220
+ const before = await revision()
221
+ await rename('left')
222
+ assert.deepEqual(await ids(), ['left', 'b', 'c'])
223
+ assert.equal((await state()).selected, 'left')
224
+ assert.equal(await revision(), before + 1)
225
+ })
226
+
227
+ test('a name another part has is refused: nothing written, the field shows the name it had, and why is said', async () => {
228
+ await pick('left')
229
+ await read(() => editing.told())
230
+ const before = await revision()
231
+ await rename('b')
232
+ assert.equal(await revision(), before, 'nothing written')
233
+ assert.deepEqual(await ids(), ['left', 'b', 'c'])
234
+ assert.equal(await name(), 'left')
235
+ const told = await read(() => editing.told())
236
+ assert.deepEqual(told.map(t => t.level), ['error'], JSON.stringify(told))
237
+ /* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key. */
238
+ assert.equal(told[0].message, 'figure.text.part-name-taken')
239
+ })
240
+ })
241
+
205
242
  describe('the list narrowed: the selected row keeps its name and its buttons in sight', () => {
206
243
  before(() => mount('box', 'three'))
207
244
  after(() => read(() => parts.narrow(0)))
@@ -221,11 +258,11 @@ describe('the list narrowed: the selected row keeps its name and its buttons in
221
258
  })
222
259
 
223
260
  describe('the palette: the starting shapes, locked at the detail level\'s limit', () => {
224
- test('nine starting shapes, in order; a rounded box or a drum is made from the box', async () => {
261
+ test('nine starting shapes, in order, then a mesh brought from a file; a rounded box or a drum is made from the box', async () => {
225
262
  await mount('box', 'three')
226
263
  assert.deepEqual(
227
264
  (await read(() => parts.palette())).map(b => b.label),
228
- ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`)
265
+ ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`).concat('figure.button.import-mesh')
229
266
  )
230
267
  })
231
268
 
@@ -0,0 +1,81 @@
1
+ /*
2
+ * A row's preview follows the row as it is set: a pitch refused, then 개수를 정함 chosen -- the pitch's refusal goes at
3
+ * once, not after the count is worked out (on :3300, 2026-10-11, it stayed under the count, as if the count were
4
+ * refused), and the count's own result is said.
5
+ *
6
+ * Run: node --test test/browser/row-preview.check.mjs
7
+ */
8
+ import { test, describe, before, after } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
12
+
13
+ import { buildPage } from './build.mjs'
14
+ import { openPage } from './serve.mjs'
15
+
16
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
17
+
18
+ let opened
19
+ before(async () => {
20
+ opened = await openPage(await buildPage('places-mesh-page.js'))
21
+ })
22
+ after(async () => {
23
+ await opened?.close()
24
+ })
25
+
26
+ const page = () => opened.page
27
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
28
+ async function until(fn, args, what, ms = 8000) {
29
+ const deadline = Date.now() + ms
30
+ for (;;) {
31
+ if (await read(fn, ...args)) return
32
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
33
+ await sleep(50)
34
+ }
35
+ }
36
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
37
+ const handle = (selector, options = {}) => page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
38
+ async function click(selector, options = {}, what = selector) {
39
+ const element = await handle(selector, options)
40
+ assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
41
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
42
+ await element.click()
43
+ await settled()
44
+ }
45
+ /** What the row's preview says now, line by line. */
46
+ const said = () => read(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words], [preview-words] li') ?? [])].map(e => e.textContent.trim()))
47
+
48
+ /* A 400 mm board, a 3 mm bin fastened on it: a row of it along X. */
49
+ function board() {
50
+ let a = addFigureBox(createFigureAsset('Board'), { id: 'board', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 25, z: 0 } })
51
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'board', face: 'bottom', to: { plane: 'mounting-plane' } })
52
+ a = addFigureBox(a, { id: 'bin', size: { x: 3, y: 100, z: 100 }, position: { x: 0, y: 100, z: 0 } })
53
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { part: 'board', face: 'top' } })
54
+ }
55
+
56
+ describe('a row\'s preview follows its basis', () => {
57
+ before(async () => {
58
+ await read(text => harness.mount('given', text), serializeFigureAsset(board()))
59
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
60
+ const at = await read(() => probe.row('bin'))
61
+ await page().mouse.click(at.x, at.y)
62
+ await until(() => probe.state().selected === 'bin', [], 'bin selected')
63
+ await click('details[row-fold] > summary', {}, 'the row\'s fold')
64
+ })
65
+
66
+ test('a 1 mm pitch: refused, said under the row', async () => {
67
+ const pitch = await handle('section[row] input[type=number]')
68
+ await pitch.evaluate(i => i.scrollIntoView({ block: 'center' }))
69
+ await pitch.click({ clickCount: 3 })
70
+ await page().keyboard.type('1')
71
+ await page().keyboard.press('Tab')
72
+ await until(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words]') ?? [])].some(e => e.textContent.includes('figure.text.preview-refused')), [], 'the pitch refused')
73
+ })
74
+
75
+ test('개수를 정함 chosen: the pitch\'s refusal is gone at once, and the count is said', async () => {
76
+ await click('section[row] input[type=radio]', { index: 1 }, 'by count')
77
+ /* Read before the count is worked out (it settles 150 ms after it is chosen). */
78
+ assert.ok(!(await said()).some(line => line.includes('preview-refused')), `not the pitch's: ${JSON.stringify(await said())}`)
79
+ await until(() => [...(findDeep('section[row]')?.querySelectorAll('[preview-words] li') ?? [])].some(e => e.textContent.includes('figure.text.row-count-pitch')), [], 'the count said')
80
+ })
81
+ })