@things-factory/figure-ui 10.1.204 → 10.1.206

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 (116) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +23 -22
  3. package/client/modeller/editor.ts +217 -184
  4. package/client/modeller/fasten-form.ts +130 -12
  5. package/client/modeller/figure-fasten-dialog.ts +45 -11
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +6 -6
  9. package/client/modeller/figure-section-editor.ts +6 -6
  10. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  11. package/client/modeller/gizmo.ts +22 -1
  12. package/client/modeller/keys.ts +10 -1
  13. package/client/modeller/links.ts +79 -46
  14. package/client/modeller/palette.ts +1 -1
  15. package/client/modeller/product-session.ts +16 -4
  16. package/client/modeller/refusal-teller.ts +3 -25
  17. package/client/modeller/refusals.ts +19 -26
  18. package/client/modeller/section-drawing.ts +7 -7
  19. package/client/modeller/size-drag.ts +112 -0
  20. package/client/modeller/slot-layout-host.ts +3 -3
  21. package/client/modeller/stage-model.ts +6 -9
  22. package/client/pages/figure-modeller-page.ts +1 -4
  23. package/dist-client/modeller/canvas.d.ts +24 -3
  24. package/dist-client/modeller/canvas.js +96 -11
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/editor-styles.js +23 -22
  27. package/dist-client/modeller/editor-styles.js.map +1 -1
  28. package/dist-client/modeller/editor.d.ts +35 -35
  29. package/dist-client/modeller/editor.js +221 -193
  30. package/dist-client/modeller/editor.js.map +1 -1
  31. package/dist-client/modeller/fasten-form.d.ts +46 -4
  32. package/dist-client/modeller/fasten-form.js +120 -11
  33. package/dist-client/modeller/fasten-form.js.map +1 -1
  34. package/dist-client/modeller/figure-fasten-dialog.d.ts +18 -4
  35. package/dist-client/modeller/figure-fasten-dialog.js +49 -12
  36. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  37. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  38. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  39. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  40. package/dist-client/modeller/figure-links.d.ts +1 -1
  41. package/dist-client/modeller/figure-links.js +2 -2
  42. package/dist-client/modeller/figure-links.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.js +4 -4
  44. package/dist-client/modeller/figure-preview.js.map +1 -1
  45. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  46. package/dist-client/modeller/figure-section-editor.js +4 -4
  47. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  48. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  49. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  50. package/dist-client/modeller/gizmo.d.ts +18 -1
  51. package/dist-client/modeller/gizmo.js +19 -0
  52. package/dist-client/modeller/gizmo.js.map +1 -1
  53. package/dist-client/modeller/keys.d.ts +1 -1
  54. package/dist-client/modeller/keys.js +6 -0
  55. package/dist-client/modeller/keys.js.map +1 -1
  56. package/dist-client/modeller/links.d.ts +30 -24
  57. package/dist-client/modeller/links.js +67 -42
  58. package/dist-client/modeller/links.js.map +1 -1
  59. package/dist-client/modeller/palette.js +1 -1
  60. package/dist-client/modeller/palette.js.map +1 -1
  61. package/dist-client/modeller/product-session.d.ts +15 -13
  62. package/dist-client/modeller/product-session.js +15 -5
  63. package/dist-client/modeller/product-session.js.map +1 -1
  64. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  65. package/dist-client/modeller/refusal-teller.js +3 -22
  66. package/dist-client/modeller/refusal-teller.js.map +1 -1
  67. package/dist-client/modeller/refusals.d.ts +6 -14
  68. package/dist-client/modeller/refusals.js +16 -27
  69. package/dist-client/modeller/refusals.js.map +1 -1
  70. package/dist-client/modeller/section-drawing.d.ts +3 -4
  71. package/dist-client/modeller/section-drawing.js +10 -9
  72. package/dist-client/modeller/section-drawing.js.map +1 -1
  73. package/dist-client/modeller/size-drag.d.ts +38 -0
  74. package/dist-client/modeller/size-drag.js +85 -0
  75. package/dist-client/modeller/size-drag.js.map +1 -0
  76. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  77. package/dist-client/modeller/slot-layout-host.js +3 -3
  78. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  79. package/dist-client/modeller/stage-model.d.ts +5 -7
  80. package/dist-client/modeller/stage-model.js +6 -6
  81. package/dist-client/modeller/stage-model.js.map +1 -1
  82. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  83. package/dist-client/pages/figure-modeller-page.js +1 -6
  84. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  85. package/dist-client/route.d.ts +1 -1
  86. package/dist-client/tsconfig.tsbuildinfo +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +7 -7
  89. package/test/align.test.ts +7 -11
  90. package/test/browser/acceptance-page.js +11 -1
  91. package/test/browser/editing.check.mjs +17 -13
  92. package/test/browser/links-page.js +2 -2
  93. package/test/browser/links.check.mjs +124 -69
  94. package/test/browser/modeller-page.js +30 -18
  95. package/test/browser/modeller-revision.check.mjs +39 -0
  96. package/test/browser/modeller.check.mjs +77 -26
  97. package/test/browser/parts.check.mjs +49 -0
  98. package/test/browser/screen-page.js +0 -2
  99. package/test/browser/screen.check.mjs +11 -13
  100. package/test/browser/section.check.mjs +29 -2
  101. package/test/fasten-form.test.ts +102 -1
  102. package/test/gizmo.test.ts +25 -16
  103. package/test/keys.test.ts +11 -0
  104. package/test/links.test.ts +46 -36
  105. package/test/refusals.test.ts +17 -21
  106. package/test/resize-with-placement.test.ts +14 -13
  107. package/test/revision.test.ts +3 -2
  108. package/test/section-editor.test.ts +9 -10
  109. package/test/size-drag.test.ts +114 -0
  110. package/test/slot-layout-host.test.ts +13 -8
  111. package/translations/en.json +32 -34
  112. package/translations/es.json +32 -34
  113. package/translations/ja.json +32 -34
  114. package/translations/ko.json +32 -34
  115. package/translations/ms.json +32 -34
  116. package/translations/zh.json +32 -34
@@ -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. */
@@ -241,7 +241,7 @@ describe('a part made to follow the figure size, and let go of it', () => {
241
241
  })
242
242
  })
243
243
 
244
- /* A base and a flange, sized; the height may be placed from 90 mm though the parts measure down to 1. */
244
+ /* A base and a flange, sized, saved with ends on every axis: the height from 90 mm, the others from 1 mm. */
245
245
  function sizedPair(turning) {
246
246
  let a = box(createFigureAsset('Probe'), 'base', [200, 100, 200], [0, 50, 0])
247
247
  a = addFigurePrimitive(a, { id: 'flange', kind: 'cylinder', dimensions: { radius: 20, length: 10 }, position: { x: 0, y: 105, z: 0 } })
@@ -256,16 +256,11 @@ function sizedPair(turning) {
256
256
  }
257
257
 
258
258
  describe('the sizes a figure may be placed at, and how it grows', () => {
259
- test('measured again, the declared range becomes the measured one in one step, and the offer goes', async () => {
259
+ test('a figure saved with ends reads them as typed, and nothing is offered to measure again', async () => {
260
260
  await mount('sized-pair', sizedPair(false))
261
261
  /* The figure's range is the 「반응형」 tab's. */
262
262
  await tab('response')
263
- const start = await state()
264
- await click(await at('button', 'figure.button.remeasure-range'), 'remeasure')
265
- await oneStep(start)
266
- const y = (await source()).document.model.inputs.find(i => i.id === 'size.y')
267
- assert.deepEqual([y.min, y.max], [1, 1000], 'the height from 1 mm, as measured')
268
- assert.equal(await at('button', 'figure.button.remeasure-range'), null, 'nothing left to measure again')
263
+ assert.equal(await at('button', 'figure.button.remeasure-range'), null, 'nothing to measure again')
269
264
  })
270
265
 
271
266
  test('a figure with no joint is not urged to grow by one ratio; declared, the rule is said', async () => {
@@ -282,6 +277,26 @@ describe('the sizes a figure may be placed at, and how it grows', () => {
282
277
  })
283
278
  })
284
279
 
280
+ /*
281
+ A link that makes the height impossible below 95 mm is made, not refused (user 2026-10-21): the smallest height the
282
+ author narrowed to, 90 mm, moves in to 95 in the same step, and one line says from what to what (ADR-0087 decision 6).
283
+ */
284
+ describe('a part change a narrowed end cannot hold', () => {
285
+ test('is one step; the end moves in, said in one line', async () => {
286
+ await mount('sized-pair-moved', sizedPair(false))
287
+ await pick('flange')
288
+ await read(() => look.hear('notify'))
289
+ const start = await state()
290
+ await read(() => look.tell('shape', 'command', { action: { kind: 'link-dimension', part: 'flange', axis: 'y', source: { of: 'instance', axis: 'y' }, plus: -95 } }))
291
+ await oneStep(start)
292
+ assert.equal((await state()).error, '', 'not refused')
293
+ const y = (await source()).document.model.inputs.find(i => i.id === 'size.y')
294
+ assert.ok(y.min > 95 && y.min < 95.001 && y.max === 1000, `the smallest height moved in: ${y.min}`)
295
+ const told = (await heard('notify')).filter(n => n?.level === 'info' && String(n.message).includes('figure.text.size-range-moved-min'))
296
+ assert.equal(told.length, 1, 'said once')
297
+ })
298
+ })
299
+
285
300
  /* A conveyor's rollers in a row across its frame, 200 mm apart. */
286
301
  function conveyor() {
287
302
  let a = fasten(box(createFigureAsset('Conveyor'), 'frame', [2000, 100, 600], [0, 50, 0]), 'frame', 'plane')
@@ -388,22 +403,22 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
388
403
  })
389
404
  const ghosts = () => read(() => look.drawn().filter(n => n.startsWith('__preview')))
390
405
 
391
- test('started, the window opens over the side panel, asking what to fasten to, nothing chosen; the canvas stays in sight', async () => {
406
+ test('started, the window opens in the standard popup, asking what to fasten to, nothing chosen', async () => {
392
407
  await pick('leaf')
393
408
  await press('button[fasten-start]')
394
409
  const list = await until(() => probe.fastenTargets(), [], 'the fastening window')
395
410
  assert.deepEqual(list.filter(t => t.selected), [], 'nothing chosen')
396
411
  assert.ok(list.some(t => t.target === 'frame' && !t.disabled), 'the frame offered')
397
412
  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`)
413
+ /* The standard popup, moved by its head, with no backdrop: the canvas can be pressed while it is open (ADR-0140 stage 4). */
414
+ assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: false, role: 'dialog' })
402
415
  })
403
416
 
404
- 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 () => {
417
+ /* Chosen in the window, the gap is the distance the leaf stands at: it is fastened where it is (ADR-0140 stage 4, 「현재 상태로 붙이기」). */
418
+ test('the frame chosen: the editor is told the command, and the canvas draws the face it meets; the leaf stays where it stands', async () => {
405
419
  await aimAt('frame')
406
- await until(() => look.drawn().includes('__preview:leaf') && look.drawn().includes('__target-face'), [], 'the leaf and the face drawn')
420
+ await until(() => look.drawn().includes('__target-face') && look.all('[preview-words]', 'figure.text.preview-stays').length > 0, [], 'the face drawn and the leaf said to stay')
421
+ assert.deepEqual(await ghosts(), [], 'nothing drawn where it would go: it does not move')
407
422
  const told = (await heard('preview-command')).filter(Boolean).at(-1)
408
423
  assert.equal(told?.action.kind, 'attach')
409
424
  assert.equal((await source()).joints?.some(j => j.body1.part === 'leaf') ?? false, false, 'nothing written')
@@ -424,15 +439,15 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
424
439
  assert.deepEqual(await ghosts(), [], 'nothing of the earlier result drawn')
425
440
  })
426
441
 
427
- test('another part picked in the list closes the window with nothing written, and nothing it tried is drawn', async () => {
442
+ test('another tab pressed closes the window with nothing written, and nothing it tried is drawn', async () => {
428
443
  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')
444
+ /* No backdrop: the press reaches the tab, and the window, which is the properties tab's, closes. */
445
+ await click(await read(l => probe.tab(l), 'figure.label.tab-short-motion'), 'the motion tab')
432
446
  await until(() => !probe.fastenTargets(), [], 'the window closed')
433
447
  await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing it tried drawn')
448
+ assert.equal(await read(() => look.tab()), 'figure.label.tab-short-motion', 'the press reached the tab')
434
449
  assert.equal((await state()).revision, start.revision, 'nothing written')
435
- await pick('leaf')
450
+ await tab('properties')
436
451
  })
437
452
 
438
453
  test('"fasten it first" from the motion tab opens this part\'s window, on what it has, on the properties tab', async () => {
@@ -445,7 +460,7 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
445
460
 
446
461
  test('Esc closes the window and tells the editor to clear what it tried; the leaf stays selected', async () => {
447
462
  await aimAt('frame')
448
- await until(() => look.drawn().includes('__preview:leaf'), [], 'the leaf drawn where it would go')
463
+ await until(() => look.drawn().includes('__target-face'), [], 'the face it would meet drawn')
449
464
  await read(() => look.hear('preview-command', 'dialog'))
450
465
  await page().keyboard.press('Escape')
451
466
  await until(() => !probe.fastenTargets(), [], 'the window closed')
@@ -461,9 +476,9 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
461
476
  await aimAt('frame')
462
477
  /*
463
478
  The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
464
- aimed once the window has drawn what the choice brings -- the leaf where it would go -- or it lands where the face was.
479
+ aimed once the window has drawn what the choice brings -- the face it would meet -- or it lands where the face was.
465
480
  */
466
- await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
481
+ await until(() => look.face('front')?.pickable && look.drawn().includes('__target-face'), [], 'the front face offered, the face it would meet drawn')
467
482
  await settled()
468
483
  await sleep(200)
469
484
  await click(await at('path[face="front"]'), 'the front face in the drawing')
@@ -490,13 +505,13 @@ async function pressFixAlignment() {
490
505
  describe('past the edge of the face a part is fastened to', () => {
491
506
  before(async () => {
492
507
  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. */
508
+ /* 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
509
  a = fasten(box(a, 'out', [100, 100, 100]), 'out', 'frame', { align: { x: { mm: 480 } } })
495
510
  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
511
  a = addFigurePrimitive(a, { id: 'tilted', kind: 'mesh', dimensions: { size: 100 }, mesh, position: { x: 0, y: 0, z: 0 } })
497
512
  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
513
  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')
514
+ a = fasten(box(a, 'cap', [50, 50, 50]), 'cap', 'drum', { align: { x: { mm: 70 }, z: { mm: 70 } } })
500
515
  await mount('overhangs', fasten(box(a, 'plate', [1600, 20, 200]), 'plate', 'frame', { align: { x: { mm: 200 } } }))
501
516
  })
502
517
  const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
@@ -524,13 +539,13 @@ describe('past the edge of the face a part is fastened to', () => {
524
539
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
525
540
  })
526
541
 
527
- test('on a round face it is not known: said so, and "fix alignment" says why it cannot', async () => {
542
+ 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
543
  await pick('cap')
529
- await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
544
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the rim')
530
545
  const start = await state()
531
546
  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')
547
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
548
+ await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'within the rim')
534
549
  })
535
550
 
536
551
  test('wider than the face: said in the relation with which side, not as a warning', async () => {
@@ -626,41 +641,32 @@ function twistedFollowingSize() {
626
641
  return applyFigureAuthoring(twisted(), { kind: 'follow-size', part: 'blade' })
627
642
  }
628
643
 
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 () => {
644
+ /*
645
+ 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
646
+ keeps it. Before, the drag was withheld and a notice sent the author to the size fields.
647
+ */
648
+ describe('a part fastened twisted is sized by a drag: its size alone, its fastening keeps it', () => {
649
+ 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
650
  await mount('twisted', twisted())
632
651
  await pick('blade')
633
- assert.equal((await at('button[tool]', 'figure.button.gizmo-scale'))?.disabled, true, 'the resize tool is off')
652
+ assert.equal((await at('button[tool]', 'figure.button.gizmo-scale'))?.disabled, false, 'the resize tool is on')
634
653
  await read(() => probe.told())
635
654
  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')
655
+ const joint = await read(() => probe.joint('blade'))
656
+ await read(() => look.transformed('blade', { scale: [1.5, 1, 1], grab: { x: 1, y: 0, z: 0 } }))
657
+ await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
658
+ assert.equal(await read(() => probe.value('blade.size.x')), 450, 'its size')
659
+ assert.deepEqual(await read(() => probe.joint('blade')), joint, 'its fastening as it was')
660
+ assert.deepEqual(await read(() => probe.told()), [], 'nothing to say')
650
661
  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
662
  })
657
663
 
658
664
  /*
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.
665
+ :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
666
+ tool's own words, and puts no gizmo on it.
661
667
  */
662
668
  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 () => {
669
+ test(`a part whose size follows ${label} is not sent to size fields it cannot type in`, async () => {
664
670
  await mount(`twisted-${make}`, make === 'twistedFollowing' ? twistedFollowing() : twistedFollowingSize())
665
671
  await pick('blade')
666
672
  await read(() => probe.told())
@@ -668,12 +674,10 @@ describe('a part fastened twisted is not sized by a drag', () => {
668
674
  await sleep(400)
669
675
  const told = await read(() => probe.told())
670
676
  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
677
  /* 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
678
  assert.equal((await state()).attached, null, 'no gizmo on it')
674
679
  assert.deepEqual(told, [{ level: 'info', message: 'figure.text.gizmo-not-own', action: null }])
675
680
  })
676
-
677
681
  })
678
682
 
679
683
  describe('a box made to follow the figure size on the floor', () => {
@@ -684,14 +688,14 @@ describe('a box made to follow the figure size on the floor', () => {
684
688
  await pick('box')
685
689
  })
686
690
 
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 () => {
691
+ 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
692
  const roomOnly = await all('input[aria-label="box.position.y"]')
689
693
  assert.equal(roomOnly.length, 1, 'one field for it, apart from the position row')
690
694
  assert.equal(roomOnly[0].disabled, true, 'not typed into')
691
695
  assert.equal((await all('section[room-only] input[aria-label="box.position.y"]')).length, 1, 'in the room\'s section')
692
696
  const held = (await all('div[vector-row] input[readonly]')).filter(i => i.label?.startsWith('position.y'))
693
697
  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')
698
+ assert.equal(await read(() => look.scalePlacement('box')), 'owner', 'its place in a size drag is the modeller\'s (ADR-0140 4.4)')
695
699
  })
696
700
 
697
701
  test('the room is declared again only when the author confirms', async () => {
@@ -798,7 +802,7 @@ describe('the fastening window: tried freely, written once', () => {
798
802
  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
803
  })
800
804
 
801
- test('the canvas is turned with the window open: nothing written, still open, the window where it was', async () => {
805
+ test('moved by the popup\'s head out of the way of the canvas: nothing written, still open', async () => {
802
806
  await pick('lamp')
803
807
  await changeFastening()
804
808
  const rect = () => read(() => {
@@ -806,21 +810,21 @@ describe('the fastening window: tried freely, written once', () => {
806
810
  return r ? { x: Math.round(r.left), y: Math.round(r.top) } : null
807
811
  })
808
812
  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 }
813
+ const head = await read(() => {
814
+ const h = window.fastenDialog().closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
815
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
813
816
  })
814
817
  const start = await state()
815
- await page().mouse.move(box.x, box.y)
818
+ await page().mouse.move(head.x, head.y)
816
819
  await page().mouse.down()
817
- for (let i = 1; i <= 6; i++) await page().mouse.move(box.x + 20 * i, box.y + 5 * i)
820
+ for (let i = 1; i <= 6; i++) await page().mouse.move(head.x - 30 * i, head.y + 20 * i)
818
821
  await page().mouse.up()
819
- await settled()
822
+ const moved = await rect()
823
+ assert.deepEqual([moved.x - was.x, moved.y - was.y], [-180, 120], `moved with the pointer: ${JSON.stringify([was, moved])}`)
820
824
  assert.equal(await windowOpen(), true, 'still open')
821
- assert.deepEqual(await rect(), was, 'the window where it was')
822
825
  assert.equal((await state()).revision, start.revision, 'nothing written')
823
- await press('[cancel]')
826
+ /* Moved down, its foot may be past the page's: closed by Esc, as the window takes it. */
827
+ await page().keyboard.press('Escape')
824
828
  await until(() => !probe.fastenTargets(), [], 'the window closed')
825
829
  })
826
830
 
@@ -857,6 +861,57 @@ describe('a motion on the motion tab', () => {
857
861
  })
858
862
  })
859
863
 
864
+ describe('pressed on the canvas while the fastening window is open (ADR-0140 stage 4)', () => {
865
+ before(async () => {
866
+ /* A drum standing at (0, 600, 0), radius 100; a tag on the floor, its fastening to change. */
867
+ let a = addFigurePrimitive(createFigureAsset('Pressed'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 600, z: 0 } })
868
+ a = fasten(box(a, 'tag', [40, 40, 40], [600, 20, 600]), 'tag', 'plane')
869
+ await mount('pressed', a)
870
+ })
871
+
872
+ test("the drum's side pressed: the window aims at the point pressed, and the tag stays chosen", async () => {
873
+ await pick('tag')
874
+ await changeFastening()
875
+ /* The scene's click, as a press on the drum's side facing +Z, 50 mm above its middle, tells it (`pickedAt`). */
876
+ const said = await read(async () => {
877
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
878
+ const canvas = deep('figure-canvas')[0]
879
+ const layer = canvas.scene.root
880
+ const drum = layer.components.find(c => c.state.name === 'drum')
881
+ drum.realObject.pickedAt = { point: [0, 650, 100], normal: [0, 0, 1] }
882
+ /* As the scene's click does: told on the component, it comes up to the board with the component as its origin. */
883
+ drum.trigger('click', new MouseEvent('click'))
884
+ await new Promise(done => setTimeout(done, 300))
885
+ const dialog = deep('figure-fasten-dialog')[0]
886
+ await dialog.updateComplete
887
+ return { target: dialog.form.target, surface: dialog.form.surface, selected: deep('figure-editor')[0].selected }
888
+ })
889
+ assert.deepEqual(said, { target: 'drum', surface: { around: 270, along: { mm: 50 } }, selected: 'tag' })
890
+ })
891
+
892
+ test('the window open, the drum pressed with the mouse on the canvas: aimed at, the window still open', async () => {
893
+ await press('[cancel]')
894
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
895
+ await changeFastening()
896
+ /* Moved by its head off the drum, as a hand does, then the drum pressed where it is drawn. */
897
+ const head = await read(() => {
898
+ const h = window.fastenDialog().closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
899
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
900
+ })
901
+ await page().mouse.move(head.x, head.y)
902
+ await page().mouse.down()
903
+ for (let i = 1; i <= 10; i++) await page().mouse.move(head.x + 35 * i, head.y)
904
+ await page().mouse.up()
905
+ await settled()
906
+ const drum = await read(() => probe.partOnPage('drum'))
907
+ 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')
908
+ await click(drum, 'the drum on the canvas')
909
+ await until(() => probe.fastenTargets()?.some(t => t.target === 'drum' && t.selected), [], 'the drum aimed at in the window')
910
+ assert.equal(await windowOpen(), true, 'still open: no backdrop took the press')
911
+ assert.equal((await state()).selected, 'tag', 'the tag still chosen')
912
+ })
913
+ })
914
+
860
915
  test('the page raised no error', () => {
861
916
  assert.deepEqual(opened.errors, [])
862
917
  })
@@ -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