@things-factory/figure-ui 10.1.244 → 10.1.246

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 (135) hide show
  1. package/client/graphql/index.ts +15 -0
  2. package/client/modeller/align.ts +51 -13
  3. package/client/modeller/clips.ts +51 -1
  4. package/client/modeller/data-interface.ts +16 -0
  5. package/client/modeller/editor-styles.ts +6 -0
  6. package/client/modeller/editor.ts +112 -25
  7. package/client/modeller/fasten-form.ts +8 -1
  8. package/client/modeller/fasten-geometry.ts +24 -2
  9. package/client/modeller/figure-clips.ts +137 -4
  10. package/client/modeller/figure-fasten-dialog.ts +1 -0
  11. package/client/modeller/figure-fasten-drawing.ts +11 -5
  12. package/client/modeller/figure-links.ts +25 -0
  13. package/client/modeller/figure-material-panel.ts +54 -4
  14. package/client/modeller/figure-parts.ts +7 -2
  15. package/client/modeller/figure-pipe-panel.ts +215 -0
  16. package/client/modeller/figure-preview.ts +125 -4
  17. package/client/modeller/figure-section-editor.ts +36 -3
  18. package/client/modeller/links.ts +24 -2
  19. package/client/modeller/material-edit.ts +9 -0
  20. package/client/modeller/maturity.ts +15 -4
  21. package/client/modeller/palette.ts +9 -0
  22. package/client/modeller/pipe-path.ts +21 -0
  23. package/client/modeller/refusals.ts +66 -1
  24. package/client/modeller/section-controls.ts +2 -1
  25. package/client/modeller/section-drawing.ts +20 -1
  26. package/client/modeller/starting-shape.ts +8 -5
  27. package/client/pages/figure-list-page.ts +15 -5
  28. package/dist-client/graphql/index.d.ts +2 -0
  29. package/dist-client/graphql/index.js +15 -0
  30. package/dist-client/graphql/index.js.map +1 -1
  31. package/dist-client/modeller/align.d.ts +28 -7
  32. package/dist-client/modeller/align.js +37 -12
  33. package/dist-client/modeller/align.js.map +1 -1
  34. package/dist-client/modeller/clips.d.ts +24 -0
  35. package/dist-client/modeller/clips.js +53 -1
  36. package/dist-client/modeller/clips.js.map +1 -1
  37. package/dist-client/modeller/data-interface.d.ts +6 -0
  38. package/dist-client/modeller/data-interface.js +17 -0
  39. package/dist-client/modeller/data-interface.js.map +1 -1
  40. package/dist-client/modeller/editor-styles.js +6 -0
  41. package/dist-client/modeller/editor-styles.js.map +1 -1
  42. package/dist-client/modeller/editor.d.ts +10 -1
  43. package/dist-client/modeller/editor.js +117 -26
  44. package/dist-client/modeller/editor.js.map +1 -1
  45. package/dist-client/modeller/fasten-form.d.ts +2 -0
  46. package/dist-client/modeller/fasten-form.js +5 -1
  47. package/dist-client/modeller/fasten-form.js.map +1 -1
  48. package/dist-client/modeller/fasten-geometry.d.ts +8 -1
  49. package/dist-client/modeller/fasten-geometry.js +22 -1
  50. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  51. package/dist-client/modeller/figure-clips.d.ts +9 -0
  52. package/dist-client/modeller/figure-clips.js +137 -4
  53. package/dist-client/modeller/figure-clips.js.map +1 -1
  54. package/dist-client/modeller/figure-fasten-dialog.js +1 -0
  55. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  56. package/dist-client/modeller/figure-fasten-drawing.d.ts +3 -1
  57. package/dist-client/modeller/figure-fasten-drawing.js +13 -5
  58. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  59. package/dist-client/modeller/figure-links.d.ts +5 -0
  60. package/dist-client/modeller/figure-links.js +22 -0
  61. package/dist-client/modeller/figure-links.js.map +1 -1
  62. package/dist-client/modeller/figure-material-panel.d.ts +2 -0
  63. package/dist-client/modeller/figure-material-panel.js +53 -4
  64. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  65. package/dist-client/modeller/figure-parts.d.ts +1 -1
  66. package/dist-client/modeller/figure-parts.js +5 -1
  67. package/dist-client/modeller/figure-parts.js.map +1 -1
  68. package/dist-client/modeller/figure-pipe-panel.d.ts +29 -0
  69. package/dist-client/modeller/figure-pipe-panel.js +221 -0
  70. package/dist-client/modeller/figure-pipe-panel.js.map +1 -0
  71. package/dist-client/modeller/figure-preview.d.ts +8 -0
  72. package/dist-client/modeller/figure-preview.js +128 -4
  73. package/dist-client/modeller/figure-preview.js.map +1 -1
  74. package/dist-client/modeller/figure-section-editor.d.ts +5 -0
  75. package/dist-client/modeller/figure-section-editor.js +33 -3
  76. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  77. package/dist-client/modeller/links.d.ts +6 -0
  78. package/dist-client/modeller/links.js +17 -2
  79. package/dist-client/modeller/links.js.map +1 -1
  80. package/dist-client/modeller/material-edit.d.ts +8 -0
  81. package/dist-client/modeller/material-edit.js +8 -0
  82. package/dist-client/modeller/material-edit.js.map +1 -1
  83. package/dist-client/modeller/maturity.js +16 -5
  84. package/dist-client/modeller/maturity.js.map +1 -1
  85. package/dist-client/modeller/palette.js +9 -0
  86. package/dist-client/modeller/palette.js.map +1 -1
  87. package/dist-client/modeller/pipe-path.d.ts +19 -0
  88. package/dist-client/modeller/pipe-path.js +14 -0
  89. package/dist-client/modeller/pipe-path.js.map +1 -0
  90. package/dist-client/modeller/refusals.d.ts +12 -1
  91. package/dist-client/modeller/refusals.js +67 -0
  92. package/dist-client/modeller/refusals.js.map +1 -1
  93. package/dist-client/modeller/section-controls.js +2 -1
  94. package/dist-client/modeller/section-controls.js.map +1 -1
  95. package/dist-client/modeller/section-drawing.d.ts +5 -0
  96. package/dist-client/modeller/section-drawing.js +19 -1
  97. package/dist-client/modeller/section-drawing.js.map +1 -1
  98. package/dist-client/modeller/starting-shape.d.ts +9 -2
  99. package/dist-client/modeller/starting-shape.js +8 -3
  100. package/dist-client/modeller/starting-shape.js.map +1 -1
  101. package/dist-client/pages/figure-list-page.d.ts +1 -0
  102. package/dist-client/pages/figure-list-page.js +12 -3
  103. package/dist-client/pages/figure-list-page.js.map +1 -1
  104. package/dist-client/tsconfig.tsbuildinfo +1 -1
  105. package/package.json +8 -8
  106. package/test/align.test.ts +28 -6
  107. package/test/browser/editing-page.js +21 -1
  108. package/test/browser/editing.check.mjs +124 -0
  109. package/test/browser/links.check.mjs +49 -8
  110. package/test/browser/modeller.check.mjs +27 -1
  111. package/test/browser/part-ids.check.mjs +60 -3
  112. package/test/browser/parts.check.mjs +2 -2
  113. package/test/browser/places-mesh.check.mjs +14 -8
  114. package/test/browser/screen.check.mjs +54 -1
  115. package/test/browser/section-page.js +7 -1
  116. package/test/browser/section.check.mjs +81 -0
  117. package/test/browser/side-panel-sweep.check.mjs +13 -0
  118. package/test/clips.test.ts +30 -1
  119. package/test/data-motion.test.ts +14 -1
  120. package/test/fasten-geometry.test.ts +13 -2
  121. package/test/joint-frames.test.ts +12 -2
  122. package/test/links.test.ts +24 -0
  123. package/test/material.test.ts +15 -1
  124. package/test/maturity.test.ts +19 -1
  125. package/test/palette-icons.test.ts +2 -1
  126. package/test/pipe-panel.test.ts +33 -0
  127. package/test/refusals.test.ts +29 -0
  128. package/test/revision.test.ts +1 -1
  129. package/test/section-editor.test.ts +17 -1
  130. package/translations/en.json +86 -3
  131. package/translations/es.json +86 -3
  132. package/translations/ja.json +86 -3
  133. package/translations/ko.json +86 -3
  134. package/translations/ms.json +86 -3
  135. package/translations/zh.json +86 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.244",
3
+ "version": "10.1.246",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.12.1",
31
- "@hatiolab/things-scene": "^10.1.104",
30
+ "@hatiolab/figure-model": "^0.12.2",
31
+ "@hatiolab/things-scene": "^10.1.109",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.43",
34
34
  "@operato/graphql": "^10.29.43",
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.244",
46
- "@things-factory/figure-service": "^10.1.244",
47
- "@things-factory/figure-viewer": "^10.1.227",
48
- "@things-factory/setting-base": "^10.1.244",
45
+ "@things-factory/ai-assistant": "^10.1.246",
46
+ "@things-factory/figure-service": "^10.1.246",
47
+ "@things-factory/figure-viewer": "^10.1.246",
48
+ "@things-factory/setting-base": "^10.1.246",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "f80402ed4e682642b9dfb3ce68d8b00d591d44ee"
51
+ "gitHead": "46c0d3c93edf765cc1f13040b1f9d03fd1d9e60b"
52
52
  }
@@ -9,9 +9,9 @@ import assert from 'node:assert/strict'
9
9
  import { readFileSync } from 'node:fs'
10
10
  import { fileURLToPath } from 'node:url'
11
11
 
12
- import { addFigureBox, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
12
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
13
13
 
14
- import { alignChanges, boxOf, canMirror, gridStepOf, mirrorChanges, niceStep } from '../client/modeller/align.ts'
14
+ import { alignChanges, boxOf, canMirror, gridStepOf, mirrorEditOf, mirrorPlanOf, niceStep } from '../client/modeller/align.ts'
15
15
  import { gizmoReachOf } from '../client/modeller/gizmo.ts'
16
16
  import { revisionOf } from './revision-of.ts'
17
17
 
@@ -61,12 +61,18 @@ test('눈금은 1 · 2 · 5 간격으로 자리와 크기를 맞추고, 크기
61
61
  assert.ok(['x', 'y', 'z'].every(a => snapped.designInputs[`body.size.${a}`]! >= step))
62
62
  })
63
63
 
64
- test('대칭은 복제본을 가운데 면 반대편 같은 거리에 둔다 · 가운데에 있으면 내지 않는다', () => {
64
+ test('대칭은 한 단계로 가운데 면 반대편에 복사하고, 붙인 부품에도 낸다 · 가운데에 있으면 내지 않는다', () => {
65
65
  const asset = probe()
66
- const copied = editFigurePart(asset, { kind: 'duplicate-part', target: 'body' })
67
- const mirrored = applied(copied, mirrorChanges(revisionOf(copied), 'body', 'body-copy', 'x'))
66
+ const mirrored = editFigurePart(asset, { kind: 'mirror-part', target: 'body', axis: 'x' })
68
67
  assert.equal(Math.round(box(mirrored, 'body-copy')!.centre.x), -Math.round(box(mirrored, 'body')!.centre.x))
69
- assert.equal(canMirror(revisionOf(probe({ x: 0, y: 50, z: 0 })), 'body', 'x', gizmoReachOf(probe() as never, 'body')), false)
68
+ assert.equal(canMirror(revisionOf(probe({ x: 0, y: 50, z: 0 })), 'body', 'x'), false)
69
+ /* A part fastened to a face: the gizmo does not move it, and its mirrored copy is still offered. */
70
+ let fastened = addFigureBox(probe({ x: 0, y: 50, z: 0 }), { id: 'arm', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 0, z: 0 } })
71
+ fastened = applyFigureAuthoring(fastened, { kind: 'attach', part: 'arm', to: { part: 'body', face: 'right' } })
72
+ assert.equal(gizmoReachOf(fastened as never, 'arm').translate, false)
73
+ assert.equal(canMirror(revisionOf(fastened), 'arm', 'x'), true)
74
+ const copied = editFigurePart(fastened, { kind: 'mirror-part', target: 'arm', axis: 'x' })
75
+ assert.equal(Math.round(box(copied, 'arm-copy')!.centre.x), -Math.round(box(copied, 'arm')!.centre.x))
70
76
  })
71
77
 
72
78
  test('붙인 부품처럼 옮길 수 없는 것은 맞추기도 없다', () => {
@@ -109,3 +115,19 @@ test('no size drag is refused for a turn; what a size typed keeps is said, in ev
109
115
  const ko = JSON.parse(read('../translations/ko.json'))
110
116
  assert.deepEqual([ko['figure.text.size-keeps-centre'], ko['figure.text.size-keeps-fastening']], ['가운데 유지', '붙인 위치 유지'])
111
117
  })
118
+
119
+ test('다리처럼 아래 붙은 것이 있으면 대칭 전에 묻는다 — 함께 가져갈 것, 이름 앞머리 짐작(X 는 좌우, Z 는 앞뒤), 움직임 (로봇개 2026-11-03)', () => {
120
+ let a = probe({ x: 0, y: 300, z: 0 })
121
+ for (const id of ['fl-hip', 'fl-shin']) a = addFigureBox(a, { id, size: { x: 40, y: 100, z: 40 }, position: { x: 0, y: 0, z: 0 } })
122
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
123
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'fl-hip', to: { part: 'body', face: 'bottom' }, align: { x: { mm: 60 } } })
124
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'fl-shin', to: { part: 'fl-hip', face: 'bottom' } })
125
+ const across = mirrorPlanOf(a, 'fl-hip', 'x')
126
+ assert.deepEqual([across.hanging, across.moves, across.asks], [['fl-shin'], false, true])
127
+ assert.deepEqual([across.form.from, across.form.to, across.form.chain], ['fl-', 'fr-', true], 'X 는 피규어의 왼쪽·오른쪽')
128
+ assert.deepEqual([mirrorPlanOf(a, 'fl-hip', 'z').form.to], ['bl-'], 'Z 는 앞·뒤')
129
+ assert.deepEqual(mirrorEditOf('fl-hip', across.form), { kind: 'mirror-part', target: 'fl-hip', axis: 'x', chain: true, prefix: { from: 'fl-', to: 'fr-' } })
130
+ assert.equal(mirrorPlanOf(a, 'fl-shin', 'x').asks, false, '아래 붙은 것도 움직임도 없으면 묻지 않는다')
131
+ const made = editFigurePart(a, mirrorEditOf('fl-hip', across.form))
132
+ assert.deepEqual(made.appearance.map(x => x.target).filter(t => t.startsWith('fr-')).sort(), ['fr-hip', 'fr-shin'])
133
+ })
@@ -8,7 +8,7 @@ import './more-open.js'
8
8
  import './modeller-page.js'
9
9
  import { i18next } from '@operato/i18n'
10
10
  import { Raycaster, Vector2 } from 'three'
11
- import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
11
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset, editFigurePart, figureOccupiedBoxesOf, figureWritersOf, parseFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
12
12
 
13
13
  const PALETTE = {
14
14
  'palette.dark': '#2b2b2b',
@@ -30,7 +30,20 @@ function box(placement, position, rotation) {
30
30
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement }))
31
31
  }
32
32
 
33
+ /* A leg hung under the box, turned 40 degrees in the saved pose: its lowest corner, not the box, is what reaches the floor. */
34
+ function legged(a) {
35
+ a = addFigureBox(a, { id: 'leg', size: { x: 60, y: 500, z: 60 }, position: { x: 0, y: 0, z: 0 } })
36
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'leg', to: { part: 'box', face: 'bottom' } })
37
+ const j = a.joints.find(k => k.body1.part === 'leg')
38
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -60, upperLimit: 60 }] } })
39
+ return serializeFigureAsset({ ...a, stateDefaults: { ...a.stateDefaults, [j.id]: 40 } })
40
+ }
41
+
33
42
  window.figures = {
43
+ /* A body standing by its position, a leg under it (robot dog 2026-11-03). */
44
+ legged: () => legged(parseFigureAsset(box('floor', { x: 0, y: 1500, z: 0 }))),
45
+ /* The same body fastened to the floor plane 1200 mm up. */
46
+ 'legged-on-plane': () => legged(applyFigureAuthoring(parseFigureAsset(box('floor', { x: 0, y: 0, z: 0 })), { kind: 'attach', part: 'box', to: { plane: 'mounting-plane' }, gap: 1200 })),
34
47
  /* 500 mm above the floor. */
35
48
  raised: () => box('floor', { x: 0, y: 700, z: 0 }),
36
49
  /* Turned 45 degrees about Z, off the floor: it meets the floor by an edge. */
@@ -44,6 +57,8 @@ window.figures = {
44
57
  a = addFigurePrimitive(a, { id: 'drum', kind: 'cylinder', dimensions: { radius: 200, length: 500 }, position: { x: 400, y: 250, z: 0 } })
45
58
  return serializeFigureAsset(a)
46
59
  },
60
+ /* The doors, the right one's clip in a motion group: a clip of a group can be copied. */
61
+ 'doors-grouped': () => serializeFigureAsset(editFigurePart(parseFigureAsset(window.figures.doors()), { kind: 'set-motion-group', group: { id: 'motion', label: 'swing', clips: ['right-swing'], defaultClip: 'right-swing', transitionSeconds: 0.2 } })),
47
62
  /* Two doors on a frame, each swinging on a clip of its own. */
48
63
  doors: () => {
49
64
  let a = figure('Doors')
@@ -168,6 +183,11 @@ window.editing = {
168
183
  told() {
169
184
  return told.splice(0).map(n => ({ level: n.level, message: n.message }))
170
185
  },
186
+ /** The lowest point of every part, in the figure's saved pose, as the session holds it. */
187
+ lowest() {
188
+ const a = parseFigureAsset(editor().session.source())
189
+ return Math.round(Math.min(...figureOccupiedBoxesOf(compileFigureAsset(a).evaluate().geometry, figureWritersOf(a)).map(b => b.centre.y - b.extent.y / 2)))
190
+ },
171
191
  /** A part's material as the session holds it. */
172
192
  material(id) {
173
193
  return editor().session.read().geometry.find(g => g.id === id)?.material ?? null
@@ -264,6 +264,23 @@ describe('put on the plane by the figure\'s placement: once, not fastened (desig
264
264
  })
265
265
  })
266
266
 
267
+ describe('put down as it stands: a body by what hangs from it (robot dog 2026-11-03)', () => {
268
+ for (const [figure, how] of [['legged', 'its position'], ['legged-on-plane', 'its gap to the plane']])
269
+ test(`${figure}: the leg's lowest point reaches the floor in one step, by ${how}`, async () => {
270
+ await mount('box', figure)
271
+ await pick('box')
272
+ const tool = await read(() => editing.at('button[plane-align]'))
273
+ assert.ok(tool, 'the tool is offered')
274
+ assert.equal(tool.title, 'figure.text.align-to-floor-hanging')
275
+ assert.ok((await read(() => editing.lowest())) > 100, 'it starts off the floor')
276
+ const start = await revision()
277
+ await click(tool)
278
+ assert.equal(await revision(), start + 1, 'one step')
279
+ assert.equal(await read(() => editing.lowest()), 0)
280
+ assert.ok((await bottomOf('box')) > 300, 'the body stands on its leg, not on the floor')
281
+ })
282
+ })
283
+
267
284
  describe('the material: colours by purpose, one step each, a glow drawn while dragged, segments for round bodies only', () => {
268
285
  before(() => mount('box', 'shapes'))
269
286
 
@@ -286,6 +303,22 @@ describe('the material: colours by purpose, one step each, a glow drawn while dr
286
303
  assert.equal((await read(() => editing.material('bin'))).token, 'palette.accent')
287
304
  })
288
305
 
306
+ test('a colour of its own, picked by its value, is one step: the part takes it and the palette carries it (robot dog, 2026-11-03)', async () => {
307
+ const start = await revision()
308
+ const found = await read(() => {
309
+ const panel = [...document.querySelectorAll('*')].flatMap(function all(e) { return e.shadowRoot ? [e, ...[...e.shadowRoot.querySelectorAll('*')].flatMap(all)] : [e] }).find(e => e.localName === 'figure-material-panel' && e.shadowRoot?.querySelector('label[own-colour] input'))
310
+ const input = panel?.shadowRoot.querySelector('label[own-colour] input')
311
+ if (!input) return false
312
+ input.value = '#ffd400'
313
+ input.dispatchEvent(new Event('change', { bubbles: true }))
314
+ return true
315
+ })
316
+ assert.ok(found, 'the own colour field beside the swatches')
317
+ await until(async () => (await revision()) === start + 1, 'one step')
318
+ assert.equal((await read(() => editing.material('bin'))).token, 'colour.ffd400')
319
+ assert.equal(await read(() => probe.source().palette['colour.ffd400']), '#ffd400')
320
+ })
321
+
289
322
  test('the glow is drawn while its slider is held, nothing written; the release writes it once', async () => {
290
323
  const start = await revision()
291
324
  const thumb = await read(() => editing.range('figure-material-panel'))
@@ -477,6 +510,42 @@ describe('a clip scrubbed and keyed (2026-09-24)', () => {
477
510
  assert.deepEqual(Object.fromEntries((await read(() => editing.clips())).map(c => [c.clip, c.keys])), { 'left-swing': 3, 'right-swing': 2 })
478
511
  })
479
512
 
513
+ /* A field of the clip panel by its label: the moment, or a key's time or value (proposal 18, 2026-11-03). */
514
+ const typeInClips = async (label, text) => {
515
+ const field = await page().evaluateHandle(label => {
516
+ const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
517
+ return deep().find(i => i.type === 'number' && i.getAttribute('aria-label') === label && i.getRootNode().host?.localName === 'figure-clips') ?? null
518
+ }, label)
519
+ assert.ok(await field.evaluate(i => !!i), `${label} in the clips`)
520
+ await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
521
+ await field.click({ clickCount: 3 })
522
+ await page().keyboard.type(text)
523
+ await page().keyboard.press('Tab')
524
+ return field
525
+ }
526
+
527
+ test('a moment typed is posed as the bar would pose it, not on the bar\'s hundredths', async () => {
528
+ const start = await revision()
529
+ await typeInClips('figure.label.clip-time', '1.37')
530
+ await until(async () => (await revision()) === start + 1, 'one step')
531
+ /* The first clip on the panel is the right door's: 80 degrees over 2 seconds, so 1.37 s is 54.8. */
532
+ const value = await read(() => probe.value('right-open'))
533
+ assert.ok(Math.abs(value - 40 * 1.37) < 1e-6, `the pose at 1.37 s: ${value}`)
534
+ })
535
+
536
+ test('a key\'s value typed in its chip is one step of the track; the first key\'s time stays at 0', async () => {
537
+ const start = await revision()
538
+ await typeInClips('figure.label.key-value 2', '55')
539
+ await until(async () => (await revision()) === start + 1, 'one step')
540
+ /* The first track's second key: whichever clip the panel lists first. */
541
+ const keys = await read(() => JSON.parse(document.querySelector('figure-editor').session.source()).drivers.flatMap(d => d.keys))
542
+ assert.ok(keys.some(k => k.value === 55), `a key at 55: ${JSON.stringify(keys)}`)
543
+ assert.equal(await read(() => {
544
+ const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
545
+ return deep().find(i => i.getAttribute('aria-label') === 'figure.label.key-seconds 1')?.readOnly
546
+ }), true)
547
+ })
548
+
480
549
  test('a track\'s name stands on its own line, the repeat choice and a small remove button under it', async () => {
481
550
  const head = await read(() => editing.trackHead())
482
551
  assert.ok(head, 'a track')
@@ -487,6 +556,61 @@ describe('a clip scrubbed and keyed (2026-09-24)', () => {
487
556
  })
488
557
  })
489
558
 
559
+ describe('a track moved round its loop, a clip copied (robot dog 2026-11-03)', () => {
560
+ before(async () => {
561
+ await mount('box', 'doors-grouped')
562
+ await click(await read(() => probe.tab('figure.label.tab-short-motion')))
563
+ })
564
+ const drivers = () => read(() => JSON.parse(document.querySelector('figure-editor').session.source()).drivers)
565
+ const inClips = selector => read(selector => {
566
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
567
+ return deep().filter(e => e.getRootNode().host?.localName === 'figure-clips').length
568
+ }, selector)
569
+ const pressIn = async (selector, index = 0) => {
570
+ const button = await page().evaluateHandle(({ selector, index }) => {
571
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
572
+ return deep().filter(e => e.getRootNode().host?.localName === 'figure-clips')[index] ?? null
573
+ }, { selector, index })
574
+ assert.ok(await button.evaluate(b => !!b), selector)
575
+ await button.evaluate(b => b.scrollIntoView({ block: 'center' }))
576
+ await button.click()
577
+ }
578
+
579
+ test('a clip in a group is copied in one step; one in none is not offered', async () => {
580
+ /* The left door's clip is in no group, the right one's is in one. */
581
+ assert.equal(await read(() => {
582
+ const deep = (root = document) => [...root.querySelectorAll('button[copy-clip]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
583
+ return deep().map(b => b.disabled).join()
584
+ }), 'true,false')
585
+ const start = await revision()
586
+ await pressIn('button[copy-clip]', 1)
587
+ await until(async () => (await revision()) === start + 1, 'one step')
588
+ const copy = (await drivers()).find(d => d.clip === 'right-swing-copy')
589
+ assert.deepEqual(copy?.keys, (await drivers()).find(d => d.clip === 'right-swing').keys)
590
+ assert.ok(await read(() => JSON.parse(document.querySelector('figure-editor').session.source()).motionGroups[0].clips.includes('right-swing-copy')), 'in the group')
591
+ })
592
+
593
+ test('a track moved one second round its two-second loop starts where it stood at one second', async () => {
594
+ const start = await revision()
595
+ await pressIn('button[copy-track]')
596
+ assert.equal(await inClips('div[copy-form]'), 1, 'the form under the track')
597
+ const later = await page().evaluateHandle(() => {
598
+ const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
599
+ return deep().find(i => i.getAttribute('aria-label') === 'figure.label.copy-track-later')
600
+ })
601
+ await later.click({ clickCount: 3 })
602
+ await page().keyboard.type('1')
603
+ await page().keyboard.press('Tab')
604
+ await pressIn('div[copy-form] button[act]')
605
+ await until(async () => (await revision()) === start + 1, 'one step')
606
+ const moved = (await drivers()).find(d => d.clip === 'left-swing' && d.state === 'left-open')
607
+ /* 0 to 80 over 2 s: a second later it starts at 40, reaches 80 at 1 s, leaps back to 0 there. */
608
+ assert.equal(moved.keys[0].value, 40)
609
+ assert.ok(moved.keys.some(k => Math.abs(k.at - 0.5) < 1e-6 && k.value === 0), JSON.stringify(moved.keys))
610
+ assert.equal(await inClips('div[copy-form]'), 0, 'the form closes')
611
+ })
612
+ })
613
+
490
614
  describe('an edit that would stop a clip is asked first (designer 2026-09-27)', () => {
491
615
  before(() => mount('box', 'doors'))
492
616
 
@@ -174,12 +174,17 @@ async function type(field, text) {
174
174
  if (now === last) break
175
175
  last = now
176
176
  }
177
- await field.click({ clickCount: 3 })
178
- const focused = await field.evaluate(i => {
179
- let at = document.activeElement
180
- while (at?.shadowRoot?.activeElement) at = at.shadowRoot.activeElement
181
- return at === i
182
- })
177
+ /* Under the whole suite's load the window can still be settling after that: a press that missed is made again where it stands now. */
178
+ let focused = false
179
+ for (let tries = 0; tries < 5 && !focused; tries++) {
180
+ if (tries) await sleep(200)
181
+ await field.click({ clickCount: 3 })
182
+ focused = await field.evaluate(i => {
183
+ let at = document.activeElement
184
+ while (at?.shadowRoot?.activeElement) at = at.shadowRoot.activeElement
185
+ return at === i
186
+ })
187
+ }
183
188
  assert.ok(focused, 'the press lands in the field')
184
189
  await page().keyboard.type(text)
185
190
  await page().keyboard.press('Tab')
@@ -340,8 +345,15 @@ describe('a row\'s pitch', () => {
340
345
  await pick('roller')
341
346
  await tab('response')
342
347
  const start = await state()
343
- await type(await handle('input[aria-label="figure.label.repeat-gap"]'), '300')
344
- await oneStep(start)
348
+ const field = await handle('input[aria-label="figure.label.repeat-gap"]')
349
+ await type(field, '300')
350
+ /* Unsteady under the whole suite's load once in a while (2026-11-01, 11-03): said with what the field and the editor hold when it does. */
351
+ try {
352
+ await oneStep(start)
353
+ } catch (error) {
354
+ const seen = { field: await field.evaluate(i => ({ value: i.value, connected: i.isConnected })), state: await state() }
355
+ throw new Error(`${error.message}: ${JSON.stringify(seen)}`)
356
+ }
345
357
  const now = await source()
346
358
  assert.equal(now.designInputs.pitch, 300, 'the input is 300')
347
359
  assert.deepEqual(now.joints.find(j => j.body1.part === 'roller').repeat.pitch, { from: { of: 'input', input: 'pitch' } }, 'the row still reads it')
@@ -434,6 +446,35 @@ describe('the fastening window on the robot arm', () => {
434
446
  })
435
447
  })
436
448
 
449
+ describe('the drawing of a turned target shows its faces where they are drawn (robot dog, 2026-11-03)', () => {
450
+ before(async () => {
451
+ let a = addFigurePrimitive(createFigureAsset('Laid'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 500, z: 0 }, rotation: { x: 0, y: 0, z: 90 } })
452
+ a = addFigureBox(a, { id: 'cap', size: { x: 40, y: 40, z: 40 }, position: { x: 500, y: 500, z: 0 } })
453
+ await mount('laid-drum', a)
454
+ })
455
+
456
+ test('a cylinder laid on its side: its end faces stand left and right, and the top drawn is no face of it', async () => {
457
+ await pick('cap')
458
+ await press('button[fasten-start]')
459
+ await until(() => probe.fastenTargets(), [], 'the fastening window')
460
+ await aimAt('drum')
461
+ const placed = () =>
462
+ read(() => {
463
+ const deep = (root = document) => [...root.querySelectorAll('[place]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
464
+ return Object.fromEntries(deep().map(e => [e.getAttribute('place'), { face: e.getAttribute('face'), pickable: (e.getAttribute('style') ?? '').includes('cursor:pointer') }]))
465
+ })
466
+ await until(() => {
467
+ const deep = (root = document) => [...root.querySelectorAll('[place]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
468
+ return deep().length === 6
469
+ }, [], 'the six places drawn')
470
+ const drawn = await placed()
471
+ assert.deepEqual([drawn.left.face, drawn.right.face], ['top', 'bottom'], 'its ends, where they are seen')
472
+ assert.equal(drawn.right.pickable, true)
473
+ assert.equal(drawn.top.pickable, false, 'the round side drawn on top is no face to fasten to')
474
+ await page().keyboard.press('Escape')
475
+ })
476
+ })
477
+
437
478
  describe('a new fastening for a door leaf: chosen in the window, seen before it is done', () => {
438
479
  before(async () => {
439
480
  await mount('door')
@@ -612,7 +612,11 @@ describe('a motion is changed where it is (2026-09-25)', () => {
612
612
  await click(await button('figure.button.change-motion'))
613
613
  const max = await page().evaluateHandle(() => {
614
614
  const deep = (root = document) => [...root.querySelectorAll('input[type=number]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
615
- const inputs = deep().filter(i => i.closest('div[pair]') && i.getRootNode().host?.tagName === 'FIGURE-LINKS')
615
+ /*
616
+ The motion form's range: the pair under its range label in degrees. The size-by-state form below it has a range
617
+ of its own (in percent, 2026-10-27), which the last pair on the panel had become.
618
+ */
619
+ const inputs = deep().filter(i => i.closest('div[pair]') && i.getRootNode().host?.tagName === 'FIGURE-LINKS' && i.closest('div[pair]').previousElementSibling?.textContent.includes('figure.label.control-range (deg)'))
616
620
  return inputs[inputs.length - 1] ?? null
617
621
  })
618
622
  assert.ok(await max.evaluate(i => !!i), 'the form is open with the range')
@@ -685,6 +689,28 @@ describe('a motion is changed where it is (2026-09-25)', () => {
685
689
  assert.deepEqual(now.motion, [was.motion[0]], 'the first axis alone, as it was')
686
690
  assert.equal(await rows(), 1)
687
691
  })
692
+
693
+ test('the point a turn passes is typed in the motion form: one step, the axis\'s through, the part not moved at rest (robot dog, 2026-11-03)', async () => {
694
+ const rest = await read(() => probe.body('upper-arm'))
695
+ await click(await button('figure.button.change-motion'), 'change the motion')
696
+ const x = await page().evaluateHandle(() => {
697
+ const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
698
+ return deep().find(i => i.getAttribute('aria-label') === 'figure.label.motion-through X') ?? null
699
+ })
700
+ assert.ok(await x.evaluate(i => !!i), 'the through cells under a turn')
701
+ await x.evaluate(i => i.scrollIntoView({ block: 'center' }))
702
+ await x.click({ clickCount: 3 })
703
+ await page().keyboard.type('50')
704
+ await page().keyboard.press('Tab')
705
+ const start = await state()
706
+ await click(await button('figure.button.apply-motion-change'), 'apply')
707
+ const after = await state()
708
+ assert.equal(after.error, '', 'not refused')
709
+ assert.equal(after.revision, start.revision + 1, 'one step')
710
+ assert.deepEqual((await jointOf('upper-arm')).motion[0].through, { x: 50 })
711
+ const moved = await read(() => probe.body('upper-arm'))
712
+ assert.deepEqual(moved.t.map(Math.round), rest.t.map(Math.round), 'at its pose now, the part where it was')
713
+ })
688
714
  })
689
715
 
690
716
  describe('the modeller made easier to find and to keep (2026-09-26 baseline)', () => {
@@ -14,6 +14,7 @@ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureA
14
14
  import { buildPage } from './build.mjs'
15
15
  import { openPage } from './serve.mjs'
16
16
 
17
+ const probeJoint = (source, part) => source.joints.find(j => j.body1.part === part).body0
17
18
  const sleep = ms => new Promise(done => setTimeout(done, ms))
18
19
 
19
20
  let opened
@@ -55,7 +56,14 @@ function rowed() {
55
56
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'box-2', to: { part: 'box-1', face: 'top' } })
56
57
  a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'box-2', axis: 'x', pitch: 300 })
57
58
  /* 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 } })
59
+ a = addFigureBox(a, { id: 'side', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
60
+ /* A part fastened to the floor box's right face: no gizmo moves it, and it is mirrored to the left face (robot dog, 2026-11-03). */
61
+ a = addFigureBox(a, { id: 'ear', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 0, z: 0 } })
62
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'ear', to: { part: 'box-1', face: 'right' }, align: { z: { mm: 100 } } })
63
+ /* A leg on the floor box's front, a shin under its hip: mirrored whole, named fr- (robot dog, 2026-11-03). */
64
+ for (const id of ['fl-hip', 'fl-shin']) a = addFigureBox(a, { id, size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 0, z: 0 } })
65
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'fl-hip', to: { part: 'box-1', face: 'front' }, align: { x: { mm: 300 } } })
66
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'fl-shin', to: { part: 'fl-hip', face: 'bottom' } })
59
67
  }
60
68
 
61
69
  describe('a new part on a figure with a row takes an id no part holds', () => {
@@ -72,8 +80,8 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
72
80
  const after = await state()
73
81
  assert.equal(after.error, '')
74
82
  const now = await parts()
75
- assert.equal(now.length, 4)
76
- const added = now.find(id => !['box-1', 'box-2', 'side'].includes(id))
83
+ assert.equal(now.length, 7)
84
+ const added = now.find(id => !['box-1', 'box-2', 'side', 'ear', 'fl-hip', 'fl-shin'].includes(id))
77
85
  assert.ok(added, now.join())
78
86
  assert.equal(after.selected, added)
79
87
  })
@@ -101,4 +109,53 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
101
109
  assert.equal((await state()).selected, copy)
102
110
  assert.equal(Math.round((await read(id => probe.body(id), copy)).t[0]), -900, 'on the other side')
103
111
  })
112
+
113
+ test('a fastened part mirrored: one step, fastened to the opposite face, no refusal', async () => {
114
+ const at = await read(() => probe.row('ear'))
115
+ await page().mouse.click(at.x, at.y)
116
+ await until(() => probe.state().selected === 'ear', [], 'ear selected')
117
+ const start = await state()
118
+ const was = await read(() => probe.body('ear'))
119
+ await click('button', { text: 'figure.button.mirror-x' }, 'mirror across X')
120
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
121
+ const after = await state()
122
+ assert.equal(after.error, '')
123
+ assert.equal(after.selected, 'ear-copy')
124
+ const copy = await read(() => probe.body('ear-copy'))
125
+ assert.equal(Math.round(copy.t[0]), -Math.round(was.t[0]), 'on the other side')
126
+ assert.equal(Math.round(copy.t[2]), Math.round(was.t[2]), 'as far along the face')
127
+ assert.deepEqual(probeJoint(await read(() => probe.source()), 'ear-copy'), { part: 'box-1', face: 'left' })
128
+ })
129
+
130
+ test('the unlimited level is offered with no part limit; chosen, one step, saved, and the list counts no limit (user 2026-11-03)', async () => {
131
+ const offered = await read(() => [...findDeep('select#detail-level').options].map(o => [o.value, o.textContent.replace(/\s+/g, ' ').trim()]))
132
+ assert.deepEqual(offered.find(([v]) => v === 'UNLIMITED'), ['UNLIMITED', 'figure.label.detail-UNLIMITED · figure.text.no-part-limit'])
133
+ const start = await state()
134
+ await read(() => {
135
+ const select = findDeep('select#detail-level')
136
+ select.value = 'UNLIMITED'
137
+ select.dispatchEvent(new Event('change', { bubbles: true }))
138
+ })
139
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
140
+ assert.equal((await read(() => probe.source())).detailLevel, 'UNLIMITED')
141
+ assert.equal(await read(() => findDeep('span[room]')?.textContent.trim()), String((await parts()).length), 'the parts counted, no limit beside them')
142
+ })
143
+
144
+ test('a leg mirrored from its hip: asked first, the shin with it, named fr-, one step', async () => {
145
+ const at = await read(() => probe.row('fl-hip'))
146
+ await page().mouse.click(at.x, at.y)
147
+ await until(() => probe.state().selected === 'fl-hip', [], 'fl-hip selected')
148
+ const start = await state()
149
+ await click('button', { text: 'figure.button.mirror-x' }, 'mirror across X')
150
+ await until(() => !!findDeep('div[mirror-form]'), [], 'the mirror asked about')
151
+ assert.equal((await state()).revision, start.revision, 'nothing made yet')
152
+ assert.deepEqual(await read(() => [...findDeep('div[mirror-form]').querySelectorAll('input')].filter(i => i.type !== 'checkbox').map(i => i.value)), ['fl-', 'fr-'], 'the names guessed')
153
+ await click('div[mirror-form] button', { text: 'figure.button.mirror-x' }, 'the form\'s mirror')
154
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
155
+ const after = await state()
156
+ assert.equal(after.error, '')
157
+ assert.equal(after.selected, 'fr-hip')
158
+ assert.deepEqual(probeJoint(await read(() => probe.source()), 'fr-shin'), { part: 'fr-hip', face: 'bottom' }, 'the shin under the copy')
159
+ assert.equal(Math.round((await read(() => probe.body('fr-hip'))).t[0]), -Math.round((await read(() => probe.body('fl-hip'))).t[0]))
160
+ })
104
161
  })
@@ -320,11 +320,11 @@ describe('the list narrowed: the selected row keeps its name and its buttons in
320
320
  })
321
321
 
322
322
  describe('the palette: the starting shapes, locked at the detail level\'s limit', () => {
323
- 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 () => {
323
+ test('ten starting shapes, in order -- the pipe (ADR-0100 §16.2) among them -- then a mesh brought from a file; a rounded box or a drum is made from the box', async () => {
324
324
  await mount('box', 'three')
325
325
  assert.deepEqual(
326
326
  (await read(() => parts.palette())).map(b => b.label),
327
- ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`).concat('figure.button.import-mesh')
327
+ ['cube', 'cylinder', 'sphere', 'frustum', 'polygon', 'pyramid', 'pipe', 'plate', 'wall', 'portal'].map(k => `figure.label.primitive-${k}`).concat('figure.button.import-mesh')
328
328
  )
329
329
  })
330
330
 
@@ -241,24 +241,30 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
241
241
  assert.equal(await has('button[data-action="use-default"]'), false, 'no default: the figure\'s layout is the only one')
242
242
  })
243
243
 
244
- test('a preset on the fork\'s top: a slot that names no type is held back with the figure\'s words', async () => {
245
- assert.equal(await read(() => findDeep('input[data-preset="width"]').value), '1200', 'the face\'s size is where the preset starts')
246
- await type('input[data-preset="slotHeight"]', {}, '1000')
247
- await click('button[data-action="start-preset"]')
248
- assert.equal(await read(() => findDeep('button[data-action="apply"]').disabled), true)
249
- assert.equal(await has('*', { within: 'ox-slot-layout-editor', has: 'figure.text.slot-accepts-required' }), true)
244
+ /*
245
+ The editor of 2026-10-26: with no layout yet the face is one slot at once, sized by the face and the room over it, put
246
+ in on Apply; a slot that names no type takes any item (ADR-0051 decisions 5 and 7, 2026-10-27). The preset's typed
247
+ size and the held-back slot that names no type this check once pressed are gone.
248
+ */
249
+ test('with no layout yet, the fork\'s top is one slot at once, sized by the face; nothing written until Apply', async () => {
250
+ const start = await state()
251
+ await until(() => !!findDeep('button[data-action="apply"]') && !findDeep('button[data-action="apply"]').disabled, [], 'Apply offered for the one slot')
252
+ assert.equal((await state()).revision, start.revision, 'nothing written yet')
253
+ assert.equal(await has('*', { within: 'ox-slot-layout-editor', has: 'figure.text.slot-accepts-required' }), false, 'a slot that names no type takes any item')
250
254
  })
251
255
 
252
256
  test('the slot names a type; Apply is one step, the layout the figure holds, its slots drawn on the fork', async () => {
253
257
  const start = await state()
254
- await choose('select[data-field="accepts-mode"]', {}, 'LIST')
258
+ await click('[data-field="accepts-mode"] button[data-value="LIST"]', {}, 'listed types only')
255
259
  await type('input[data-field="accepts"]', {}, 'PALLET')
256
260
  await click('button[data-action="apply"]')
257
261
  await until(r => probe.state().revision === r + 1, [start.revision], 'the layout written')
258
262
  const held = (await source()).document.capabilities.slotLayout
259
263
  assert.equal(held.role, 'seat')
260
264
  assert.deepEqual(held.anchors.map(a => [a.id, a.frame]), [['fork', 'fork.face.top.frame']], 'on the fork\'s top')
261
- assert.deepEqual(held.layout.tiers[0].layout.slot, { allowed: { width: 1200, depth: 900, height: 1000 }, accepts: ['PALLET'] })
265
+ const slot = held.layout.tiers[0].layout.slot
266
+ assert.deepEqual([slot.allowed.width, slot.allowed.depth, slot.accepts], [1200, 900, ['PALLET']], 'the face\'s size, the type named')
267
+ assert.ok(slot.allowed.height > 0, 'a height from the room over the face')
262
268
  assert.equal(held.layout.preset.key, 'single')
263
269
  await until(() => look.drawn().includes('__slot:t1_s1'), [], 'the slot drawn')
264
270
  })