@things-factory/figure-ui 10.1.203 → 10.1.205

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/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +91 -9
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +86 -8
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +82 -2
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -93,7 +93,7 @@ const tab = name => read(l => probe.tab(l), `figure.label.tab-short-${name}`).th
93
93
  /** A fastening the part has is said in one line; 「바꾸기」 opens the fastening window on it. */
94
94
  async function changeFastening() {
95
95
  await until(() => look.all('button[change-fastening]').length === 1, [], 'the fastening said in one line')
96
- await (await handle('button[change-fastening]')).click()
96
+ await press('button[change-fastening]')
97
97
  await until(() => !!probe.fastenTargets(), [], 'the fastening window open')
98
98
  }
99
99
  /** The window's list: each row's part and words, whether chosen, whether it can be. Null while the window is closed. */
@@ -388,17 +388,15 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
388
388
  })
389
389
  const ghosts = () => read(() => look.drawn().filter(n => n.startsWith('__preview')))
390
390
 
391
- test('started, the window opens over the side panel, asking what to fasten to, nothing chosen; the canvas stays in sight', async () => {
391
+ test('started, the window opens in the standard popup, asking what to fasten to, nothing chosen', async () => {
392
392
  await pick('leaf')
393
393
  await press('button[fasten-start]')
394
394
  const list = await until(() => probe.fastenTargets(), [], 'the fastening window')
395
395
  assert.deepEqual(list.filter(t => t.selected), [], 'nothing chosen')
396
396
  assert.ok(list.some(t => t.target === 'frame' && !t.disabled), 'the frame offered')
397
397
  assert.deepEqual((await all('[apply]')).map(b => b.disabled), [true], 'nothing to apply yet')
398
- /* Not the modal popup (user 2026-10-19: it covered the 3D it was choosing for): over the side panel, at most its edge over the canvas. */
399
- const panel = await read(() => probe.fastenPanel())
400
- assert.deepEqual({ ...panel, overCanvas: undefined }, { title: 'figure.label.fastening', role: 'dialog', popup: false, overCanvas: undefined })
401
- assert.ok(panel.overCanvas <= 0.1, `the canvas stays in sight: ${panel.overCanvas} of its width covered`)
398
+ /* The standard popup, moved by its head, with no backdrop: the canvas can be pressed while it is open (ADR-0140 stage 4). */
399
+ assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: false, role: 'dialog' })
402
400
  })
403
401
 
404
402
  test('the frame chosen: the editor is told the command, and the canvas draws where the leaf would go and the face it meets', async () => {
@@ -424,15 +422,15 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
424
422
  assert.deepEqual(await ghosts(), [], 'nothing of the earlier result drawn')
425
423
  })
426
424
 
427
- test('another part picked in the list closes the window with nothing written, and nothing it tried is drawn', async () => {
425
+ test('another tab pressed closes the window with nothing written, and nothing it tried is drawn', async () => {
428
426
  const start = await state()
429
- /* No backdrop over the page: the parts list stays reachable. The window stands over the side panel, its tabs too, as the view settings do. */
430
- assert.ok((await read(() => probe.fastenPanel())).overCanvas <= 0.1)
431
- await pick('frame')
427
+ /* No backdrop: the press reaches the tab, and the window, which is the properties tab's, closes. */
428
+ await click(await read(l => probe.tab(l), 'figure.label.tab-short-motion'), 'the motion tab')
432
429
  await until(() => !probe.fastenTargets(), [], 'the window closed')
433
430
  await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing it tried drawn')
431
+ assert.equal(await read(() => look.tab()), 'figure.label.tab-short-motion', 'the press reached the tab')
434
432
  assert.equal((await state()).revision, start.revision, 'nothing written')
435
- await pick('leaf')
433
+ await tab('properties')
436
434
  })
437
435
 
438
436
  test('"fasten it first" from the motion tab opens this part\'s window, on what it has, on the properties tab', async () => {
@@ -490,13 +488,13 @@ async function pressFixAlignment() {
490
488
  describe('past the edge of the face a part is fastened to', () => {
491
489
  before(async () => {
492
490
  let a = fasten(box(createFigureAsset('Overhangs'), 'frame', [1000, 100, 400], [0, 50, 0]), 'frame', 'plane')
493
- /* Pushed 30 mm past the frame's right edge; a mesh turned on it, its box past the edge; on a drum's round end; wider than the frame, off to one side. */
491
+ /* Pushed 30 mm past the frame's right edge; a mesh turned on it, its box past the edge; on a drum's round end, within its square and past its rim; wider than the frame, off to one side. */
494
492
  a = fasten(box(a, 'out', [100, 100, 100]), 'out', 'frame', { align: { x: { mm: 480 } } })
495
493
  const mesh = { asset: 'a'.repeat(64), format: 'glb', unit: 'mm', bounds: { min: { x: -50, y: -50, z: -50 }, max: { x: 50, y: 50, z: 50 } }, triangles: 12, primitives: 1 }
496
494
  a = addFigurePrimitive(a, { id: 'tilted', kind: 'mesh', dimensions: { size: 100 }, mesh, position: { x: 0, y: 0, z: 0 } })
497
495
  a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'tilted', type: 'fixed', body0: { part: 'frame', face: 'top' }, body1: { part: 'tilted' }, origin: { align: { x: { mm: 460 } }, childRotate: { y: 30 } } } })
498
496
  a = fasten(addFigurePrimitive(a, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 200 }, position: { x: 0, y: 0, z: 0 } }), 'drum', 'frame')
499
- a = fasten(box(a, 'cap', [50, 50, 50]), 'cap', 'drum')
497
+ a = fasten(box(a, 'cap', [50, 50, 50]), 'cap', 'drum', { align: { x: { mm: 70 }, z: { mm: 70 } } })
500
498
  await mount('overhangs', fasten(box(a, 'plate', [1600, 20, 200]), 'plate', 'frame', { align: { x: { mm: 200 } } }))
501
499
  })
502
500
  const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
@@ -524,13 +522,13 @@ describe('past the edge of the face a part is fastened to', () => {
524
522
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
525
523
  })
526
524
 
527
- test('on a round face it is not known: said so, and "fix alignment" says why it cannot', async () => {
525
+ test('on a round face, measured on the disc itself: past its rim though within its square, and "fix alignment" brings it in (ADR-0140 3.9)', async () => {
528
526
  await pick('cap')
529
- await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
527
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the rim')
530
528
  const start = await state()
531
529
  await pressFixAlignment()
532
- await until(() => look.all('p[fix-note]', 'figure.text.fix-alignment-unmeasured').length === 1, [], 'why it cannot')
533
- assert.equal((await state()).revision, start.revision, 'nothing written')
530
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
531
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'within the rim')
534
532
  })
535
533
 
536
534
  test('wider than the face: said in the relation with which side, not as a warning', async () => {
@@ -626,41 +624,32 @@ function twistedFollowingSize() {
626
624
  return applyFigureAuthoring(twisted(), { kind: 'follow-size', part: 'blade' })
627
625
  }
628
626
 
629
- describe('a part fastened twisted is not sized by a drag', () => {
630
- test('the resize tool is off, and a size drag that still arrives is not written: a notice says why, in one line, with the way to its size', async () => {
627
+ /*
628
+ ADR-0140 4.1 · 4.4: a part fastened at any angle is sized by a drag too -- its size alone is written, and its fastening
629
+ keeps it. Before, the drag was withheld and a notice sent the author to the size fields.
630
+ */
631
+ describe('a part fastened twisted is sized by a drag: its size alone, its fastening keeps it', () => {
632
+ test('the resize tool is on, and a size drag writes its size in one step, the fastening as it was, with nothing said', async () => {
631
633
  await mount('twisted', twisted())
632
634
  await pick('blade')
633
- assert.equal((await at('button[tool]', 'figure.button.gizmo-scale'))?.disabled, true, 'the resize tool is off')
635
+ assert.equal((await at('button[tool]', 'figure.button.gizmo-scale'))?.disabled, false, 'the resize tool is on')
634
636
  await read(() => probe.told())
635
637
  const start = await state()
636
- await read(() => look.transformed('blade', { scale: [1.5, 1, 1] }))
637
- await settled()
638
- assert.equal((await state()).revision, start.revision, 'not written')
639
- /* A notice, not the error's red (user 2026-10-19: the reason read as a fault, and was not understood). */
640
- assert.deepEqual(await read(() => probe.told()), [{ level: 'info', message: 'figure.text.gizmo-size-turned-fastening', action: 'figure.button.to-size-fields' }])
641
- assert.equal((await state()).error, '', 'no error')
642
- })
643
-
644
- test('the R key says why, and the notice\'s button puts the cursor in the part\'s size field', async () => {
645
- await mount('twisted', twisted())
646
- await pick('blade')
647
- await read(() => probe.told())
648
- await page().keyboard.press('KeyR')
649
- await until(() => probe.told().some(n => n.message === 'figure.text.gizmo-size-turned-fastening' && n.action === 'figure.button.to-size-fields'), [], 'the reason said with its way on')
638
+ const joint = await read(() => probe.joint('blade'))
639
+ await read(() => look.transformed('blade', { scale: [1.5, 1, 1], grab: { x: 1, y: 0, z: 0 } }))
640
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
641
+ assert.equal(await read(() => probe.value('blade.size.x')), 450, 'its size')
642
+ assert.deepEqual(await read(() => probe.joint('blade')), joint, 'its fastening as it was')
643
+ assert.deepEqual(await read(() => probe.told()), [], 'nothing to say')
650
644
  assert.equal((await state()).error, '', 'no error')
651
- /* From another tab: the button brings the properties tab and the size field. */
652
- await tab('motion')
653
- assert.equal(await read(() => probe.pressNoticeButton()), true)
654
- await until(() => probe.focused()?.label?.startsWith('blade.size.'), [], 'the blade\'s size field focused')
655
- assert.equal(await read(() => look.tab()), 'figure.label.tab-short-properties')
656
645
  })
657
646
 
658
647
  /*
659
- :3300, 2026-10-19 (the fan's blade-2): a part whose size is not its own to type was told it was attached turned and
660
- offered a way to size fields it could not type in. Its size is worked out, so it is said so, with no button.
648
+ :3300, 2026-10-19 (the fan's blade-2): a part whose size is not its own to type has the resize tool off; R says the
649
+ tool's own words, and puts no gizmo on it.
661
650
  */
662
651
  for (const [label, make] of [['another part', 'twistedFollowing'], ['the figure\'s size', 'twistedFollowingSize']])
663
- test(`a part whose size follows ${label} is not told it is attached turned, nor sent to size fields it cannot type in`, async () => {
652
+ test(`a part whose size follows ${label} is not sent to size fields it cannot type in`, async () => {
664
653
  await mount(`twisted-${make}`, make === 'twistedFollowing' ? twistedFollowing() : twistedFollowingSize())
665
654
  await pick('blade')
666
655
  await read(() => probe.told())
@@ -668,12 +657,10 @@ describe('a part fastened twisted is not sized by a drag', () => {
668
657
  await sleep(400)
669
658
  const told = await read(() => probe.told())
670
659
  assert.deepEqual(told.filter(n => n.action), [], `no way to size fields: ${JSON.stringify(told)}`)
671
- assert.ok(!told.some(n => n.message === 'figure.text.gizmo-size-turned-fastening'), 'not said to be attached turned')
672
660
  /* R does what the bar's resize tool does, off for this part: no gizmo it cannot use, and the tool's own words (:3300, 10.1.196). */
673
661
  assert.equal((await state()).attached, null, 'no gizmo on it')
674
662
  assert.deepEqual(told, [{ level: 'info', message: 'figure.text.gizmo-not-own', action: null }])
675
663
  })
676
-
677
664
  })
678
665
 
679
666
  describe('a box made to follow the figure size on the floor', () => {
@@ -684,14 +671,14 @@ describe('a box made to follow the figure size on the floor', () => {
684
671
  await pick('box')
685
672
  })
686
673
 
687
- test('its height is the room\'s only, shown apart; its row shows the number the floor gives; a size drag keeps the bottom', async () => {
674
+ test('its height is the room\'s only, shown apart; its row shows the number the floor gives; a size drag is drawn by the modeller', async () => {
688
675
  const roomOnly = await all('input[aria-label="box.position.y"]')
689
676
  assert.equal(roomOnly.length, 1, 'one field for it, apart from the position row')
690
677
  assert.equal(roomOnly[0].disabled, true, 'not typed into')
691
678
  assert.equal((await all('section[room-only] input[aria-label="box.position.y"]')).length, 1, 'in the room\'s section')
692
679
  const held = (await all('div[vector-row] input[readonly]')).filter(i => i.label?.startsWith('position.y'))
693
680
  assert.deepEqual(held.map(i => i.value), ['50'], 'the height the floor gives, read-only')
694
- assert.deepEqual(await read(() => look.scaleAnchor('box')), { x: 0, y: 1, z: 0 }, 'the bottom kept')
681
+ assert.equal(await read(() => look.scalePlacement('box')), 'owner', 'its place in a size drag is the modeller\'s (ADR-0140 4.4)')
695
682
  })
696
683
 
697
684
  test('the room is declared again only when the author confirms', async () => {
@@ -798,7 +785,7 @@ describe('the fastening window: tried freely, written once', () => {
798
785
  await until(w => JSON.stringify(JSON.parse(document.querySelector('figure-editor').session.source()).joints.find(j => j.body1.part === 'lamp')) === w, [JSON.stringify(was)], 'one undo: the fastening as it was')
799
786
  })
800
787
 
801
- test('the canvas is turned with the window open: nothing written, still open, the window where it was', async () => {
788
+ test('moved by the popup\'s head out of the way of the canvas: nothing written, still open', async () => {
802
789
  await pick('lamp')
803
790
  await changeFastening()
804
791
  const rect = () => read(() => {
@@ -806,21 +793,21 @@ describe('the fastening window: tried freely, written once', () => {
806
793
  return r ? { x: Math.round(r.left), y: Math.round(r.top) } : null
807
794
  })
808
795
  const was = await rect()
809
- /* A drag on the empty canvas, left of the parts: the camera turns, nothing is picked. */
810
- const box = await read(() => {
811
- const r = document.querySelector('figure-editor').shadowRoot.querySelector('figure-canvas').getBoundingClientRect()
812
- return { x: r.left + 40, y: r.top + r.height / 2 }
796
+ const head = await read(() => {
797
+ const h = window.fastenDialog().closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
798
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
813
799
  })
814
800
  const start = await state()
815
- await page().mouse.move(box.x, box.y)
801
+ await page().mouse.move(head.x, head.y)
816
802
  await page().mouse.down()
817
- for (let i = 1; i <= 6; i++) await page().mouse.move(box.x + 20 * i, box.y + 5 * i)
803
+ for (let i = 1; i <= 6; i++) await page().mouse.move(head.x - 30 * i, head.y + 20 * i)
818
804
  await page().mouse.up()
819
- await settled()
805
+ const moved = await rect()
806
+ assert.deepEqual([moved.x - was.x, moved.y - was.y], [-180, 120], `moved with the pointer: ${JSON.stringify([was, moved])}`)
820
807
  assert.equal(await windowOpen(), true, 'still open')
821
- assert.deepEqual(await rect(), was, 'the window where it was')
822
808
  assert.equal((await state()).revision, start.revision, 'nothing written')
823
- await press('[cancel]')
809
+ /* Moved down, its foot may be past the page's: closed by Esc, as the window takes it. */
810
+ await page().keyboard.press('Escape')
824
811
  await until(() => !probe.fastenTargets(), [], 'the window closed')
825
812
  })
826
813
 
@@ -857,6 +844,57 @@ describe('a motion on the motion tab', () => {
857
844
  })
858
845
  })
859
846
 
847
+ describe('pressed on the canvas while the fastening window is open (ADR-0140 stage 4)', () => {
848
+ before(async () => {
849
+ /* A drum standing at (0, 600, 0), radius 100; a tag on the floor, its fastening to change. */
850
+ let a = addFigurePrimitive(createFigureAsset('Pressed'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 600, z: 0 } })
851
+ a = fasten(box(a, 'tag', [40, 40, 40], [600, 20, 600]), 'tag', 'plane')
852
+ await mount('pressed', a)
853
+ })
854
+
855
+ test("the drum's side pressed: the window aims at the point pressed, and the tag stays chosen", async () => {
856
+ await pick('tag')
857
+ await changeFastening()
858
+ /* The scene's click, as a press on the drum's side facing +Z, 50 mm above its middle, tells it (`pickedAt`). */
859
+ const said = await read(async () => {
860
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
861
+ const canvas = deep('figure-canvas')[0]
862
+ const layer = canvas.scene.root
863
+ const drum = layer.components.find(c => c.state.name === 'drum')
864
+ drum.realObject.pickedAt = { point: [0, 650, 100], normal: [0, 0, 1] }
865
+ /* As the scene's click does: told on the component, it comes up to the board with the component as its origin. */
866
+ drum.trigger('click', new MouseEvent('click'))
867
+ await new Promise(done => setTimeout(done, 300))
868
+ const dialog = deep('figure-fasten-dialog')[0]
869
+ await dialog.updateComplete
870
+ return { target: dialog.form.target, surface: dialog.form.surface, selected: deep('figure-editor')[0].selected }
871
+ })
872
+ assert.deepEqual(said, { target: 'drum', surface: { around: 270, along: { mm: 50 } }, selected: 'tag' })
873
+ })
874
+
875
+ test('the window open, the drum pressed with the mouse on the canvas: aimed at, the window still open', async () => {
876
+ await press('[cancel]')
877
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
878
+ await changeFastening()
879
+ /* Moved by its head off the drum, as a hand does, then the drum pressed where it is drawn. */
880
+ const head = await read(() => {
881
+ const h = window.fastenDialog().closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
882
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
883
+ })
884
+ await page().mouse.move(head.x, head.y)
885
+ await page().mouse.down()
886
+ for (let i = 1; i <= 10; i++) await page().mouse.move(head.x + 35 * i, head.y)
887
+ await page().mouse.up()
888
+ await settled()
889
+ const drum = await read(() => probe.partOnPage('drum'))
890
+ assert.ok(await read(p => { const r = window.fastenDialog().getBoundingClientRect(); return p.x < r.left || p.x > r.right || p.y < r.top || p.y > r.bottom }, drum), 'the drum clear of the window')
891
+ await click(drum, 'the drum on the canvas')
892
+ await until(() => probe.fastenTargets()?.some(t => t.target === 'drum' && t.selected), [], 'the drum aimed at in the window')
893
+ assert.equal(await windowOpen(), true, 'still open: no backdrop took the press')
894
+ assert.equal((await state()).selected, 'tag', 'the tag still chosen')
895
+ })
896
+ })
897
+
860
898
  test('the page raised no error', () => {
861
899
  assert.deepEqual(opened.errors, [])
862
900
  })
@@ -62,6 +62,15 @@ function sourceOf(kind, text) {
62
62
  asset.palette = { ...asset.palette, ...PALETTE }
63
63
  return serializeFigureAsset(addFigureBox(asset, { id: 'room', size: { x: 4000, y: 3000, z: 6000 }, position: { x: 0, y: 1500, z: 0 } }))
64
64
  }
65
+ if (kind === 'shelf') {
66
+ /* A bed on the floor and a slat on its top: a part that can be made a row (the fold checks). */
67
+ let asset = createFigureAsset('Shelf')
68
+ asset.palette = { ...asset.palette, ...PALETTE }
69
+ asset = addFigureBox(asset, { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
70
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', to: { plane: 'mounting-plane' } })
71
+ asset = addFigureBox(asset, { id: 'slat', size: { x: 60, y: 20, z: 600 }, position: { x: 0, y: 410, z: 0 } })
72
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', to: { part: 'bed', face: 'top' } }))
73
+ }
65
74
  if (kind === 'rollers') {
66
75
  /* A roller row whose rollers spin in place, each carrying a mark (ADR-0093, 2026-09-26). */
67
76
  let asset = createFigureAsset('Rollers')
@@ -71,7 +80,8 @@ function sourceOf(kind, text) {
71
80
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 40, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
72
81
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', facing: 'centre', to: { part: 'bed', face: 'top' }, gap: 40 })
73
82
  asset = addFigureBox(asset, { id: 'mark', size: { x: 30, y: 30, z: 560 }, position: { x: 0, y: 0, z: 0 } })
74
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'mark', to: { part: 'roller', face: 'right' } })
83
+ /* On the roller's side at 0° round its own Y -- its own +X (ADR-0140 3.9). */
84
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'mark', to: { part: 'roller', surface: { around: 0, along: 'centre' } } })
75
85
  const j = asset.joints.find(o => o.body1.part === 'roller')
76
86
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Z', lowerLimit: -360, upperLimit: 360, state: { id: 'spin' } } })
77
87
  return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 }))
@@ -232,23 +242,10 @@ window.probe = {
232
242
  const chip = deep('figure-fasten-dialog')[0]?.shadowRoot.querySelector('ox-input-select-buttons')?.shadowRoot?.querySelector(`md-filter-chip[data-value="${CSS.escape(part)}"]`)
233
243
  return chip ? centreOf(chip) : null
234
244
  },
235
- /**
236
- * The fastening window as it stands: its title, its role, whether a popup or its backdrop is anywhere on the page, and
237
- * how much of the canvas it covers (a share of the canvas's width).
238
- */
239
- fastenPanel() {
240
- const dialog = deep('figure-fasten-dialog')[0]
241
- const panel = dialog?.closest('[fasten-panel]')
242
- if (!panel) return null
243
- const p = panel.getBoundingClientRect(),
244
- c = canvas().getBoundingClientRect()
245
- const across = Math.max(0, Math.min(p.right, c.right) - Math.max(p.left, c.left))
246
- return {
247
- title: panel.querySelector('h3')?.textContent.trim(),
248
- role: dialog.getAttribute('role'),
249
- popup: deep('ox-floating-overlay').length > 0,
250
- overCanvas: Math.round((across / c.width) * 100) / 100
251
- }
245
+ /** The fastening window's popup, as the standard overlay holds it: its title, whether its head moves it, its backdrop. */
246
+ fastenPopup() {
247
+ const overlay = deep('figure-fasten-dialog')[0]?.closest('ox-floating-overlay')
248
+ return overlay ? { title: overlay.title, movable: overlay.movable, backdrop: !!overlay.backdrop, role: overlay.querySelector('figure-fasten-dialog').getAttribute('role') } : null
252
249
  },
253
250
  /** What the notification surface was handed since the last call: its level, its words, its button's label. */
254
251
  told() {
@@ -380,6 +377,21 @@ window.probe = {
380
377
  const b = deep('button[mode]').find(b => b.title === `figure.text.mode-${mode}-explained`)
381
378
  return b ? centreOf(b) : null
382
379
  },
380
+ /**
381
+ * A size drag as the scene tells it (ADR-0140 4.4): a frame of it -- the part grown by `scale` along its own axes, the
382
+ * handle `grab` held -- or its release. The scene's own gizmo is not driven: what the canvas tells the editor is.
383
+ */
384
+ sizeFrame(id, scale, grab) {
385
+ canvas().dispatchEvent(new CustomEvent('part-sizing', { detail: { id, result: { scale, grab } }, bubbles: true, composed: true }))
386
+ },
387
+ sizeRelease(id, scale, grab) {
388
+ canvas().dispatchEvent(new CustomEvent('part-transformed', { detail: { id, result: { scale, grab } }, bubbles: true, composed: true }))
389
+ },
390
+ /** Where the canvas is told to draw a part now: its pose as given to the canvas. */
391
+ drawnPose(id) {
392
+ const part = canvas().parts.find(p => p.id === id)
393
+ return part ? { t: [...part.pose.t], r: part.pose.r.map(row => [...row]) } : null
394
+ },
383
395
  /** The drawn position of a part's component, as the canvas shows it now. */
384
396
  drawn(id) {
385
397
  const c = canvas().scene.root.components.find(c => c.state.name === id)
@@ -109,6 +109,45 @@ describe('work counted on the robot arm', () => {
109
109
  })
110
110
  })
111
111
 
112
+ /*
113
+ ADR-0140 4.4: a size drag is drawn from figure-model frame by frame -- the figure compiled once when the drag begins, the
114
+ instance evaluator stepped each frame -- and the release writes what the last frame drew: no position differs, and the
115
+ release compiles no more than a release with no frames before it.
116
+ */
117
+ describe('a size drag drawn frame by frame on the robot arm', () => {
118
+ const grab = { x: 0, y: 1, z: 0 }
119
+ const far = (a, b) => Math.max(...a.t.map((v, i) => Math.abs(v - b.t[i])), ...a.r.flat().map((v, i) => Math.abs(v - b.r.flat()[i])))
120
+
121
+ test('each frame compiles no figure, and moves what hangs from the part', async () => {
122
+ await mount('arm')
123
+ await click(await read(id => probe.partOnPage(id), 'upper-arm'))
124
+ await settled()
125
+ const still = await read(() => probe.drawnPose('forearm'))
126
+ await read(() => probe.sizeFrame('upper-arm', [1, 1.1, 1], { x: 0, y: 1, z: 0 }))
127
+ await drawn()
128
+ for (const s of [1.2, 1.3]) {
129
+ const { spent } = await counted(() => read(s => probe.sizeFrame('upper-arm', [1, s, 1], { x: 0, y: 1, z: 0 }), s))
130
+ assert.equal(spent.compiles, 0, `${s}: ${JSON.stringify(spent)}`)
131
+ }
132
+ assert.ok((await read(() => probe.drawnPose('forearm'))).t[1] > still.t[1] + 1, 'the forearm rides up with it')
133
+ })
134
+
135
+ test('the release writes what the last frame drew: no position differs', async () => {
136
+ const before = await read(() => probe.drawnPose('forearm'))
137
+ const revision = (await read(() => probe.state())).revision
138
+ const { spent } = await counted(() => read(g => probe.sizeRelease('upper-arm', [1, 1.3, 1], g), grab))
139
+ assert.equal((await read(() => probe.state())).revision, revision + 1, 'one step')
140
+ const written = await read(() => probe.body('forearm'))
141
+ assert.ok(far(before, written) < 1e-6, `the forearm where the last frame drew it: ${far(before, written)}`)
142
+ /* Released with no frames before it, on the same figure: the release costs the same or more. */
143
+ await mount('arm')
144
+ await click(await read(id => probe.partOnPage(id), 'upper-arm'))
145
+ await settled()
146
+ const bare = await counted(() => read(g => probe.sizeRelease('upper-arm', [1, 1.3, 1], g), grab))
147
+ assert.ok(spent.compiles <= bare.spent.compiles, `released after frames ${JSON.stringify(spent)}, without ${JSON.stringify(bare.spent)}`)
148
+ })
149
+ })
150
+
112
151
  describe('the edit canvas and the preview stay standing', () => {
113
152
  before(() => mount('box'))
114
153
 
@@ -388,6 +388,18 @@ describe('a figure made in the modeller is given its size from its parts (user 2
388
388
  await tab('response')
389
389
  })
390
390
 
391
+ /* In words a person reads (user 2026-10-20): what the figure does on a drawing, not the range behind it. */
392
+ const sizeGroup = () =>
393
+ page().evaluate(() => {
394
+ const deep = (root = document) => [...root.querySelectorAll('section[board-size]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
395
+ const s = deep()[0]
396
+ return s ? { title: [...(s.querySelector('h3')?.childNodes ?? [])].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim(), fixed: s.querySelector('p[fixed-size]')?.textContent.trim() ?? null, followers: s.querySelector('p[followers]')?.textContent.trim() ?? null, dashes: s.querySelectorAll('div[pair][range] > span[dash]').length } : null
397
+ })
398
+
399
+ test('fixed: the size it is always drawn at, in one sentence, and the way to let a drawing resize it', async () => {
400
+ assert.deepEqual(await sizeGroup(), { title: 'figure.label.board-size', fixed: 'figure.text.board-size-fixed', followers: null, dashes: 0 })
401
+ })
402
+
391
403
  test('with nothing picked, the figure offers to declare its size; clicked, its size fields appear', async () => {
392
404
  const button = await page().evaluateHandle(() => {
393
405
  const deep = (root = document) => [...root.querySelectorAll('button')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
@@ -407,6 +419,10 @@ describe('a figure made in the modeller is given its size from its parts (user 2
407
419
  assert.deepEqual(await read(() => probe.fields('size.')), { 'size.x': 400, 'size.y': 400, 'size.z': 400 }, 'the size it is authored at: its parts now')
408
420
  })
409
421
 
422
+ test('resizable: each axis reads smallest – largest, and which parts change with it (none on a lone box)', async () => {
423
+ assert.deepEqual(await sizeGroup(), { title: 'figure.label.board-size', fixed: null, followers: 'figure.text.board-size-no-followers', dashes: 3 })
424
+ })
425
+
410
426
  test('the sizes it may be placed at are typed: the largest width, one step, and a smallest past its size refused', async () => {
411
427
  const field = id =>
412
428
  page().evaluateHandle(id => {
@@ -615,9 +631,15 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
615
631
  assert.equal(await resize.evaluate(b => b.getAttribute('aria-pressed')), 'false')
616
632
  })
617
633
 
618
- test('a height typed into its field keeps the bottom on the floor, as the gizmo does', async () => {
634
+ test('a height typed into its field keeps the part\'s middle, and the size group says so (ADR-0140 4.1)', async () => {
619
635
  await mount('box')
620
636
  await click(await read(() => probe.partOnPage('box')))
637
+ const was = await body('box')
638
+ const middleOf = b => b.t[1] + b.dimensions.start + b.dimensions.length / 2
639
+ assert.ok(await read(() => {
640
+ const deep = (root = document) => [...root.querySelectorAll('p[size-keeps]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
641
+ return deep().some(p => p.textContent.trim() === 'figure.text.size-keeps-centre')
642
+ }), 'said beside the size fields')
621
643
  const height = await find('input', 'box.size.y')
622
644
  assert.ok(await height.evaluate(i => !!i), 'the height field')
623
645
  const at = await centre(height)
@@ -626,8 +648,8 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
626
648
  await page().keyboard.press('Enter')
627
649
  await sleep(600)
628
650
  const now = await body('box')
629
- /* The box is drawn up y from its start: its bottom is its centre plus the start. */
630
- assert.equal(Math.round(now.t[1] + now.dimensions.start), 0, `bottom on the floor: centre ${now.t[1]}, starts at ${now.dimensions.start}`)
651
+ /* The box is drawn along y from its start: its middle is its origin plus the start plus half the length. */
652
+ assert.equal(Math.round(middleOf(now)), Math.round(middleOf(was)), `its middle where it was: ${middleOf(was)} -> ${middleOf(now)}`)
631
653
  assert.equal(now.dimensions.length, 1000)
632
654
  })
633
655
 
@@ -895,14 +917,14 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
895
917
 
896
918
  describe('the row section opens on one click', () => {
897
919
  test('a part that can be made a row: one click on the folded heading opens it, a second folds it', async () => {
898
- await mount('rollers')
899
- await click(await read(() => probe.row('mark')))
920
+ await mount('shelf')
921
+ await click(await read(() => probe.row('slat')))
900
922
  await tab('response')
901
923
  const summary = await page().evaluateHandle(() => {
902
924
  const deep = (root = document) => [...root.querySelectorAll('details[row-fold] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
903
925
  return deep()[0] ?? null
904
926
  })
905
- assert.ok(await summary.evaluate(s => !!s && !!s.parentElement.querySelector('button[act]')), 'the mark is offered a row to make')
927
+ assert.ok(await summary.evaluate(s => !!s && !!s.parentElement.querySelector('button[act]')), 'the slat is offered a row to make')
906
928
  const at = await summary.evaluate(s => {
907
929
  s.scrollIntoView({ block: 'center' })
908
930
  const r = s.getBoundingClientRect()
@@ -915,9 +937,9 @@ describe('the row section opens on one click', () => {
915
937
  })
916
938
 
917
939
  test('a part picked again: a fold opened at once stays open -- the folds kept for the part are put back first', async () => {
918
- await mount('rollers')
919
- await click(await read(() => probe.row('mark')))
920
- await click(await read(() => probe.row('roller')))
940
+ await mount('shelf')
941
+ await click(await read(() => probe.row('slat')))
942
+ await click(await read(() => probe.row('bed')))
921
943
  const open = await read(async () => {
922
944
  const deep = (selector, root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(selector, e.shadowRoot)))
923
945
  const editor = deep('figure-editor')[0]
@@ -925,7 +947,7 @@ describe('the row section opens on one click', () => {
925
947
  The panel is drawn for the part once the editor and its links have updated; a click can come any time after
926
948
  that, before a frame is drawn. Here it comes right then.
927
949
  */
928
- editor.selected = 'mark'
950
+ editor.selected = 'slat'
929
951
  await editor.updateComplete
930
952
  await Promise.all(deep('figure-links').map(links => links.updateComplete))
931
953
  await null
@@ -8,7 +8,6 @@ import './modeller-page.js'
8
8
  import { captureFigureThumbnail } from 'figure-ui-client/modeller/thumbnail.js'
9
9
  import { drawableFigureParts } from 'figure-ui-client/modeller/renderer.js'
10
10
  import { previewTypeOf } from '@things-factory/figure-viewer/dist-client/preview-model.js'
11
- import { figureReportOf } from 'figure-ui-client/modeller/maturity.js'
12
11
  import { hasFigure } from '@hatiolab/things-scene'
13
12
  import {
14
13
  addFigureBox,
@@ -118,7 +117,6 @@ window.screenCheck = {
118
117
  registered: type => hasFigure(type),
119
118
  previewTypeOf,
120
119
  /** The report the verdict tab measures, for the figure the editor holds. */
121
- capacityNow: () => figureReportOf(window.screenCheck.editor().session.revisionNow().asset)?.capacity.instances,
122
120
  /** The card's picture of a figure (saved text), as a save takes it. */
123
121
  async picture(text) {
124
122
  const asset = JSON.parse(text)
@@ -223,9 +223,9 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
223
223
  await pressTab(TAB('properties'))
224
224
  })
225
225
 
226
- test('the verdict tab carries the count the page measured', async () => {
227
- await read(() => (screenCheck.editor().instances = 42))
228
- await until('the count on the tab', () => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="figure.label.tab-short-verdict"] span[badge]`)?.textContent.trim() === '42')
226
+ /* The count once drawn on the verdict tab covered its icon and was not something to act on (2026-10-20): none. */
227
+ test('the verdict tab carries no count over its icon', async () => {
228
+ assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelectorAll('button[tab] span[badge]').length), 0)
229
229
  })
230
230
 
231
231
  test('nothing chosen: the properties panel opens on the figure\'s own settings, with no part head', async () => {
@@ -240,8 +240,11 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
240
240
  assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').pane === screenCheck.editor().shadowRoot.querySelector('ox-three-pane')), true, 'the bar folds the editor\'s own frame')
241
241
  const titles = await read(() => ['undo', 'redo'].map(a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`).title))
242
242
  assert.ok(titles[0].endsWith('(Ctrl+Z)') && titles[1].endsWith('(Ctrl+Y)'), `the keys in the titles: ${titles}`)
243
- const status = () => read(() => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim())
244
- assert.equal(await status(), 'figure.text.saved')
243
+ /* Saved or not is the save button's: pressable with something to save, dimmed without -- no words beside it. */
244
+ const status = () => read(() => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim() ?? null)
245
+ const saveOn = () => read(() => !screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]').disabled)
246
+ assert.equal(await status(), null, 'nothing said while nothing is under way')
247
+ assert.equal(await saveOn(), false, 'saved: the save button is dimmed')
245
248
 
246
249
  await pressRow('box')
247
250
  const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.y'))
@@ -249,14 +252,15 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
249
252
  await page().keyboard.type('600')
250
253
  await page().keyboard.press('Enter')
251
254
  await until('the height is 600', () => probe.value('box.size.y') === 600)
252
- await until('said unsaved', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim() === 'figure.text.unsaved-changes')
255
+ await until('something to save', () => !screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]').disabled)
256
+ assert.equal(await status(), null, 'and still no words beside it')
253
257
 
254
258
  await pressBar('undo')
255
259
  await until('undone', () => probe.value('box.size.y') === 400)
256
260
  await pressBar('redo')
257
261
  await until('redone', () => probe.value('box.size.y') === 600)
258
262
  await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
259
- await until('said saved', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar [role=status]')?.textContent.trim() === 'figure.text.saved')
263
+ await until('saved: dimmed again', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]').disabled)
260
264
 
261
265
  const width = () => read(() => Math.round(screenCheck.deep('figure-canvas')[0].getBoundingClientRect().width))
262
266
  const was = await width()
@@ -1077,12 +1081,6 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1077
1081
  await press('the release button', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[publish]'))
1078
1082
  })
1079
1083
 
1080
- test('the verdict tab\'s count is the report\'s, measured by the verdict', async () => {
1081
- const expected = await read(() => screenCheck.capacityNow())
1082
- assert.ok(expected > 0)
1083
- await until('the count on the tab', expected => screenCheck.editor().shadowRoot.querySelector('button[tab][aria-label="figure.label.tab-short-verdict"] span[badge]')?.textContent.trim() === expected.toLocaleString(), expected)
1084
- })
1085
-
1086
1084
  test('a new type asks the verdict again', async () => {
1087
1085
  await pressTab(TAB('properties'))
1088
1086
  await pressRow('left')
@@ -291,11 +291,11 @@ describe('the section editor on a box: what follows, what is kept, and a figure
291
291
  await openEditor()
292
292
  })
293
293
 
294
- test('the length says the face its placement keeps', async () => {
294
+ test('the length says what a length typed keeps: the part\'s middle (ADR-0140 4.1)', async () => {
295
295
  const now = await editorNow()
296
296
  assert.equal(now.length.label, 'figure.label.section-editor-length')
297
297
  assert.equal(now.length.disabled, false)
298
- assert.equal(now.length.note, 'figure.text.section-editor-keeps')
298
+ assert.equal(now.length.note, 'figure.text.size-keeps-centre')
299
299
  })
300
300
 
301
301
  test('the figure changed while the editor is open: apply is refused, and closing does not offer it', async () => {