@things-factory/figure-ui 10.1.151 → 10.1.153

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 (86) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +65 -634
  5. package/client/modeller/fasten-geometry.ts +32 -0
  6. package/client/modeller/figure-first-save.ts +9 -1
  7. package/client/modeller/figure-links.ts +32 -14
  8. package/client/modeller/mesh-import.ts +0 -10
  9. package/client/modeller/modeller-keys.ts +60 -0
  10. package/client/modeller/pane-fold.ts +61 -0
  11. package/client/modeller/panel-memory.ts +53 -0
  12. package/client/modeller/refusal-teller.ts +93 -0
  13. package/client/modeller/refusals.ts +32 -2
  14. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  15. package/dist-client/modeller/ai-proposal.js +9 -9
  16. package/dist-client/modeller/ai-proposal.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  19. package/dist-client/modeller/command-preview-controller.js +178 -0
  20. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  21. package/dist-client/modeller/editor-styles.d.ts +2 -0
  22. package/dist-client/modeller/editor-styles.js +307 -0
  23. package/dist-client/modeller/editor-styles.js.map +1 -0
  24. package/dist-client/modeller/editor.d.ts +5 -56
  25. package/dist-client/modeller/editor.js +76 -656
  26. package/dist-client/modeller/editor.js.map +1 -1
  27. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  28. package/dist-client/modeller/fasten-geometry.js +29 -0
  29. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  30. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  31. package/dist-client/modeller/figure-first-save.js +8 -1
  32. package/dist-client/modeller/figure-first-save.js.map +1 -1
  33. package/dist-client/modeller/figure-links.d.ts +9 -2
  34. package/dist-client/modeller/figure-links.js +35 -14
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/mesh-import.d.ts +0 -2
  37. package/dist-client/modeller/mesh-import.js +0 -11
  38. package/dist-client/modeller/mesh-import.js.map +1 -1
  39. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  40. package/dist-client/modeller/modeller-keys.js +43 -0
  41. package/dist-client/modeller/modeller-keys.js.map +1 -0
  42. package/dist-client/modeller/pane-fold.d.ts +15 -0
  43. package/dist-client/modeller/pane-fold.js +60 -0
  44. package/dist-client/modeller/pane-fold.js.map +1 -0
  45. package/dist-client/modeller/panel-memory.d.ts +15 -0
  46. package/dist-client/modeller/panel-memory.js +54 -0
  47. package/dist-client/modeller/panel-memory.js.map +1 -0
  48. package/dist-client/modeller/product-session.d.ts +4 -28
  49. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  50. package/dist-client/modeller/refusal-teller.js +81 -0
  51. package/dist-client/modeller/refusal-teller.js.map +1 -0
  52. package/dist-client/modeller/refusals.d.ts +12 -1
  53. package/dist-client/modeller/refusals.js +26 -1
  54. package/dist-client/modeller/refusals.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/build.mjs +7 -2
  58. package/test/browser/editing.check.mjs +14 -0
  59. package/test/browser/fix-alignment.check.mjs +112 -0
  60. package/test/browser/links.check.mjs +69 -13
  61. package/test/browser/mesh-select.check.mjs +91 -0
  62. package/test/browser/modeller-page.js +36 -0
  63. package/test/browser/modeller-revision.check.mjs +18 -6
  64. package/test/browser/modeller.check.mjs +25 -0
  65. package/test/browser/part-ids.check.mjs +24 -6
  66. package/test/browser/parts-page.js +9 -1
  67. package/test/browser/parts.check.mjs +61 -2
  68. package/test/browser/places-mesh.check.mjs +25 -9
  69. package/test/browser/product-page.check.mjs +42 -2
  70. package/test/browser/row-preview.check.mjs +81 -0
  71. package/test/browser/screen.check.mjs +35 -1
  72. package/test/clip-ai-proposal.test.ts +4 -1
  73. package/test/clip-consent.test.ts +9 -2
  74. package/test/fields.test.ts +5 -9
  75. package/test/fix-alignment.test.ts +32 -0
  76. package/test/layout.test.ts +3 -1
  77. package/test/mesh-import.test.ts +1 -8
  78. package/test/parts-wiring.test.ts +1 -5
  79. package/test/refusals.test.ts +46 -10
  80. package/test/theme.test.ts +5 -15
  81. package/translations/en.json +8 -0
  82. package/translations/es.json +8 -0
  83. package/translations/ja.json +8 -0
  84. package/translations/ko.json +8 -0
  85. package/translations/ms.json +8 -0
  86. 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.153",
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.3",
31
31
  "@hatiolab/things-scene": "^10.1.83",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
@@ -42,8 +42,8 @@
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
44
  "@things-factory/ai-assistant": "^10.1.150",
45
- "@things-factory/figure-service": "^10.1.151",
45
+ "@things-factory/figure-service": "^10.1.153",
46
46
  "three": "^0.185.1"
47
47
  },
48
- "gitHead": "495d06ef9ecf4d19fc9bf45ebc3cff4fdfefca53"
48
+ "gitHead": "54b3e985a55d5d0b01622d87f385f07518fb5d16"
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,112 @@
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
+ /* The editor and the part's panels have drawn what they were last told: a read never meets the draw before. */
39
+ const settled = () =>
40
+ read(async () => {
41
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
42
+ const drawing = () => [document.querySelector('figure-editor'), ...deep('figure-links')].filter(Boolean)
43
+ for (let round = 0; round < 20; round++) {
44
+ await Promise.all(drawing().map(e => e.updateComplete))
45
+ if (!drawing().some(e => e.isUpdatePending)) return
46
+ }
47
+ })
48
+ /** Pressed once it is on the page and the panels have drawn -- waited for, not looked up once. */
49
+ async function click(selector, options = {}, what = selector) {
50
+ await settled()
51
+ await until((s, o) => !!findDeep(s, o), [selector, options], `${what} on the page`)
52
+ const element = await page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
53
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
54
+ await element.click()
55
+ await settled()
56
+ }
57
+ async function pick(part) {
58
+ const at = await read(id => probe.row(id), part)
59
+ assert.ok(at, `the row of ${part}`)
60
+ await page().mouse.click(at.x, at.y)
61
+ await until(id => probe.state().selected === id, [part], `${part} selected`)
62
+ }
63
+ const jointOf = part => read(p => probe.source().joints.find(j => j.body1.part === p), part)
64
+ const words = () => read(() => [...(findDeep('div[overhang]')?.querySelectorAll('p') ?? [])].map(p => p.textContent.trim()))
65
+
66
+ /* A 400 × 1000 deck; a 100 mm block on it, fastened 1000 mm off its centre along X -- past its low edge. */
67
+ function figure(withChild = false) {
68
+ let a = addFigureBox(createFigureAsset('Deck'), { id: 'deck', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 25, z: 0 } })
69
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'deck', face: 'bottom', to: { plane: 'mounting-plane' } })
70
+ a = addFigureBox(a, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 100, z: 0 } })
71
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'deck', face: 'top' }, ...(withChild ? {} : { align: { x: { mm: -1000 } } }) })
72
+ if (!withChild) return a
73
+ /* The block inside; a cap on it, past the block's edge. */
74
+ a = addFigureBox(a, { id: 'cap', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 170, z: 0 } })
75
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'block', face: 'top' }, align: { x: { mm: 300 } } })
76
+ }
77
+
78
+ describe('정렬·간격 고치기 moves a part inside the face it is fastened on', () => {
79
+ before(async () => {
80
+ await read(text => harness.mount('given', text), serializeFigureAsset(figure()))
81
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
82
+ await pick('block')
83
+ })
84
+
85
+ test('out past the low edge: said, then moved to that edge as one step, and no longer said', async () => {
86
+ await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
87
+ assert.ok((await words()).length > 0, 'the overhang is said')
88
+ const start = await state()
89
+ await click('button[fix-alignment]')
90
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
91
+ assert.deepEqual((await jointOf('block')).origin.align?.x, 'min')
92
+ await until(() => !findDeep('div[overhang]'), [], 'nothing said of an overhang')
93
+ assert.equal((await state()).error, '')
94
+ })
95
+ })
96
+
97
+ describe('when nothing of the part is out, the button says why', () => {
98
+ before(async () => {
99
+ await read(text => harness.mount('given', text), serializeFigureAsset(figure(true)))
100
+ await until(() => look.drawn().length > 0, [], 'the board is drawn')
101
+ await pick('block')
102
+ })
103
+
104
+ test('what is out is the cap fastened on it: said under the button, nothing written', async () => {
105
+ await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
106
+ const start = await state()
107
+ await click('button[fix-alignment]')
108
+ await until(() => !!findDeep('p[fix-note]'), [], 'the note')
109
+ assert.match(await read(() => findDeep('p[fix-note]').textContent.trim()), /^figure\.text\.fix-alignment-inside/)
110
+ assert.equal((await state()).revision, start.revision)
111
+ })
112
+ })
@@ -101,6 +101,28 @@ const handle = (selector, test) =>
101
101
  selector,
102
102
  test
103
103
  )
104
+ /**
105
+ * Pressed as the panels stand once drawn: no fastening panel with an update still to draw, then the element itself, not a
106
+ * point read before. A point read too early is pressed after the panel has moved under it (2026-10-11: the door leaf's
107
+ * "fasten" read, then its scroll put back a frame later, and the click fell on another button).
108
+ */
109
+ async function press(selector, text, what = text ?? selector) {
110
+ await read(async () => {
111
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
112
+ for (let round = 0; round < 20 && deep('figure-links').some(e => e.isUpdatePending); round++) await Promise.all(deep('figure-links').map(e => e.updateComplete))
113
+ })
114
+ const element = await page().evaluateHandle(
115
+ (selector, text) => {
116
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
117
+ return deep(selector).find(e => text === undefined || e.textContent.replace(/\s+/g, ' ').trim().includes(text)) ?? null
118
+ },
119
+ selector,
120
+ text
121
+ )
122
+ assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
123
+ await element.click()
124
+ await settled()
125
+ }
104
126
  /** Chooses an option of the select labelled `label`, as a person does: the select changes, once. */
105
127
  async function choose(label, value, selector = 'select') {
106
128
  const select = await handle(selector, label)
@@ -113,7 +135,24 @@ async function choose(label, value, selector = 'select') {
113
135
  async function type(field, text) {
114
136
  assert.ok(await field.evaluate(i => !!i), 'the field is on the page')
115
137
  await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
138
+ /*
139
+ Pressed where it stands once the panel has stopped moving: what a fold opening or a preview's words bring is drawn
140
+ after the click that brought it, and a press aimed before that lands on whatever moved under it.
141
+ */
142
+ const where = () => field.evaluate(i => JSON.stringify(i.getBoundingClientRect()))
143
+ for (let last = await where(), i = 0; i < 40; i++) {
144
+ await sleep(100)
145
+ const now = await where()
146
+ if (now === last) break
147
+ last = now
148
+ }
116
149
  await field.click({ clickCount: 3 })
150
+ const focused = await field.evaluate(i => {
151
+ let at = document.activeElement
152
+ while (at?.shadowRoot?.activeElement) at = at.shadowRoot.activeElement
153
+ return at === i
154
+ })
155
+ assert.ok(focused, 'the press lands in the field')
117
156
  await page().keyboard.type(text)
118
157
  await page().keyboard.press('Tab')
119
158
  await settled()
@@ -305,7 +344,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
305
344
 
306
345
  test('started, it asks what to fasten to, with nothing chosen', async () => {
307
346
  await pick('leaf')
308
- await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
347
+ await press('button[act]', 'figure.button.fasten-start')
309
348
  /* The form is the panel's, drawn after the editor's update. */
310
349
  const target = await until(() => look.select('figure.label.target-part'), [], 'the fastening form')
311
350
  assert.deepEqual([target.value, target.options[0].text], ['', 'figure.label.target-part-placeholder'])
@@ -351,7 +390,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
351
390
 
352
391
  test('"fasten it first" from the motion tab opens this part\'s fastening, as it is now, in view, on the properties tab', async () => {
353
392
  await pick('leaf')
354
- await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
393
+ await press('button[act]', 'figure.button.fasten-start')
355
394
  await choose('figure.label.target-part', 'frame')
356
395
  /* The panel scrolled back to its head, the fastening below the fold. */
357
396
  const panel = await at('section[fastening]')
@@ -369,8 +408,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
369
408
  test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
370
409
  const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
371
410
  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')
411
+ /*
412
+ The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
413
+ aimed once the form has drawn what the choice brings -- the leaf where it would go, and the words above the drawing
414
+ that move it down -- or it lands where the face was.
415
+ */
416
+ await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
417
+ await settled()
418
+ await sleep(200)
374
419
  await click(await at('path[face="front"]'), 'the front face in the drawing')
375
420
  await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
376
421
  const start = await state()
@@ -383,6 +428,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
383
428
  })
384
429
  })
385
430
 
431
+ /*
432
+ * 정렬·간격 고치기 pressed as the panel stands once it has drawn: a point read before it has, then clicked, can land where
433
+ * the button was, and nothing happens.
434
+ */
435
+ async function pressFixAlignment() {
436
+ await press('div[overhang] button[fix-alignment]', undefined, 'fix alignment')
437
+ }
438
+
386
439
  describe('past the edge of the face a part is fastened to', () => {
387
440
  before(async () => {
388
441
  let a = fasten(box(createFigureAsset('Overhangs'), 'frame', [1000, 100, 400], [0, 50, 0]), 'frame', 'plane')
@@ -395,11 +448,13 @@ describe('past the edge of the face a part is fastened to', () => {
395
448
  })
396
449
  const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
397
450
 
398
- test('past it: said, and "fix alignment" puts the focus on the face in the drawing', async () => {
451
+ test('past it: said, and "fix alignment" moves it inside the face as one step', async () => {
399
452
  await pick('out')
400
453
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the edge')
401
- 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')
454
+ const start = await state()
455
+ await pressFixAlignment()
456
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
457
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'no longer past the edge')
403
458
  })
404
459
 
405
460
  test('turned, it may be past it: said so', async () => {
@@ -407,9 +462,13 @@ describe('past the edge of the face a part is fastened to', () => {
407
462
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
408
463
  })
409
464
 
410
- test('on a round face it is not known: said so', async () => {
465
+ test('on a round face it is not known: said so, and "fix alignment" says why it cannot', async () => {
411
466
  await pick('cap')
412
467
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
468
+ const start = await state()
469
+ await pressFixAlignment()
470
+ await until(() => look.all('p[fix-note]', 'figure.text.fix-alignment-unmeasured').length === 1, [], 'why it cannot')
471
+ assert.equal((await state()).revision, start.revision, 'nothing written')
413
472
  })
414
473
 
415
474
  test('wider than the face: said in the relation with which side, not as a warning', async () => {
@@ -460,11 +519,8 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
460
519
  await pick('lamp')
461
520
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
462
521
  await read(() => look.hear('preview-command'))
463
- /*
464
- Clicked until it is open: the first click puts the focus in the row, which opens the fold, and the summary's own
465
- toggle then closes it again -- two clicks open it (seen 2026-10-11).
466
- */
467
- for (let i = 0; i < 2 && !(await all('details[row-fold]'))[0].open; i++) await click(await at('details[row-fold] > summary'), 'the row')
522
+ /* One click opens it, and it stays open while the row is worked. */
523
+ await click(await at('details[row-fold] > summary'), 'the row')
468
524
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [true], 'open while it is worked')
469
525
  await until(() => look.all('section[row] input[type=number]').length > 0, [], 'the pitch field in the open row')
470
526
  await type(await handle('section[row] input[type=number]'), '250')
@@ -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
  })
@@ -900,6 +900,31 @@ describe('the row section opens on one click', () => {
900
900
  await click(at)
901
901
  assert.equal(await summary.evaluate(s => s.parentElement.open), false, 'folded after a second')
902
902
  })
903
+
904
+ test('a part picked again: a fold opened at once stays open -- the folds kept for the part are put back first', async () => {
905
+ await mount('rollers')
906
+ await click(await read(() => probe.row('mark')))
907
+ await click(await read(() => probe.row('roller')))
908
+ const open = await read(async () => {
909
+ const deep = (selector, root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(selector, e.shadowRoot)))
910
+ const editor = deep('figure-editor')[0]
911
+ /*
912
+ The panel is drawn for the part once the editor and its links have updated; a click can come any time after
913
+ that, before a frame is drawn. Here it comes right then.
914
+ */
915
+ editor.selected = 'mark'
916
+ await editor.updateComplete
917
+ await Promise.all(deep('figure-links').map(links => links.updateComplete))
918
+ await null
919
+ const summary = deep('details[row-fold] > summary')[0]
920
+ summary.click()
921
+ const fold = summary.parentElement
922
+ const opened = fold.open
923
+ await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
924
+ return [opened, fold.open]
925
+ })
926
+ assert.deepEqual(open, [true, true], 'opened by the click, and still open a frame later')
927
+ })
903
928
  })
904
929
 
905
930
  test('the page raised no error', () => {
@@ -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
  })
@@ -5,7 +5,7 @@
5
5
  * Only reading: every change the check makes goes through the mouse and the keyboard.
6
6
  */
7
7
  import './editing-page.js'
8
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
9
9
 
10
10
  const PALETTE = {
11
11
  'palette.dark': '#2b2b2b',
@@ -36,6 +36,14 @@ Object.assign(window.figures, {
36
36
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } }))
37
37
  },
38
38
  /* A box with rounded corners: made from the box, still called what it is. */
39
+ /* A housing and a stray part on the floor, the space the figure takes declared from both. */
40
+ gate: () => {
41
+ let a = figure('Gate')
42
+ a = addFigureBox(a, { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 500, z: 0 } })
43
+ a = addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 600, y: 50, z: 0 } })
44
+ for (const part of ['housing', 'stray']) a = applyFigureAuthoring(a, { kind: 'attach', part, face: 'bottom', to: { plane: 'mounting-plane' } })
45
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
46
+ },
39
47
  rounded: () => {
40
48
  let a = figure('Rounded')
41
49
  a = addFigureBox(a, { id: 'tray', size: { x: 600, y: 200, z: 400 }, position: { x: 0, y: 100, z: 0 } })