@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
@@ -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 => {
@@ -436,8 +452,8 @@ describe('a figure made in the modeller is given its size from its parts (user 2
436
452
  assert.equal((await declared())['size.x'][1], 3000, 'the largest width is what was typed')
437
453
  await type('size.x.min', '500')
438
454
  assert.notEqual((await state()).error, '', 'the figure is 400 wide: a smallest of 500 is refused')
439
- assert.equal((await declared())['size.x'][0] < 400, true, 'and the range is as it was')
440
- assert.equal(await (await field('size.x.min')).evaluate(i => Number(i.value) < 400), true, 'the field shows the range it still has')
455
+ assert.equal((await declared())['size.x'][0] ?? null, null, 'and the smallest is still open (ADR-0087 decision 6)')
456
+ assert.deepEqual(await (await field('size.x.min')).evaluate(i => [i.value, i.placeholder]), ['', '0'], 'the field empty again, what the parts allow written faintly in it')
441
457
  })
442
458
 
443
459
  /*
@@ -451,6 +467,34 @@ describe('a figure made in the modeller is given its size from its parts (user 2
451
467
  assert.notEqual(said, 'figure.text.edit-refused', `said in its own words: ${said}`)
452
468
  })
453
469
 
470
+ test('a field emptied opens its end again, in one step: the largest is what the parts allow, nothing', async () => {
471
+ const field = () =>
472
+ page().evaluateHandle(() => {
473
+ const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
474
+ return deep().find(i => i.getAttribute('aria-label') === 'size.x.max') ?? null
475
+ })
476
+ /* Selected where it is drawn, as a person does, then typed over. */
477
+ const typeOver = async keys => {
478
+ const at = await (await field()).evaluate(i => {
479
+ i.scrollIntoView({ block: 'center' })
480
+ const r = i.getBoundingClientRect()
481
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
482
+ })
483
+ await page().mouse.click(at.x, at.y, { clickCount: 3 })
484
+ await keys()
485
+ await page().keyboard.press('Tab')
486
+ await sleep(600)
487
+ }
488
+ await typeOver(() => page().keyboard.type('3000'))
489
+ assert.equal(await (await field()).evaluate(i => i.value), '3000', 'narrowed first')
490
+ const start = await state()
491
+ await typeOver(() => page().keyboard.press('Backspace'))
492
+ const after = await state()
493
+ assert.equal(after.error, '', 'not refused')
494
+ assert.equal(after.revision, start.revision + 1, 'one step')
495
+ assert.deepEqual(await (await field()).evaluate(i => [i.value, i.placeholder]), ['', '∞'], 'empty, and nothing limits it')
496
+ })
497
+
454
498
  test('a part follows the figure\'s width less a difference: chosen and typed, the width is the size less it', async () => {
455
499
  await click(await read(() => probe.partOnPage('box')))
456
500
  assert.equal((await state()).selected, 'box')
@@ -484,23 +528,17 @@ describe('a figure made in the modeller is given its size from its parts (user 2
484
528
  await page().keyboard.type('-50')
485
529
  await page().keyboard.press('Tab')
486
530
  await sleep(600)
487
- /* The declared sizes start at 1 mm: less 50 the box would be gone below 50, so the link is refused -- with the way on. */
488
- assert.match((await state()).error, /figure.text.link-needs-smaller-range/, 'refused, said in words')
489
- /* A refusal with a way on is asked in the app's prompt (OxPrompt): confirmed, the narrowed link is made. */
531
+ /*
532
+ The sizes the figure may be drawn at are worked out from its parts (ADR-0087 decision 6), not refused against a
533
+ range nobody chose: the link is taken as typed, with nothing asked.
534
+ */
535
+ assert.equal((await state()).error, '', 'taken')
536
+ assert.equal((await body('box')).dimensions['size.x'], 350, 'the figure\'s 400 less 50')
490
537
  const asked = await read(() => {
491
538
  const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
492
- const prompt = deep().at(-1)
493
- const confirm = prompt?.shadowRoot?.querySelector('#confirm')
494
- if (!confirm) return null
495
- const r = confirm.getBoundingClientRect()
496
- return { text: prompt.text, label: confirm.textContent.trim(), x: r.left + r.width / 2, y: r.top + r.height / 2 }
539
+ return deep().length
497
540
  })
498
- assert.match(asked?.text ?? '', /figure.text.link-needs-smaller-range/, 'asked in the app\'s prompt')
499
- assert.match(asked.label, /figure.button.narrow-range-from/, 'the same link is offered with the sizes narrowed')
500
- await click(asked)
501
- await sleep(600)
502
- assert.equal((await state()).error, '', 'taken')
503
- assert.equal((await body('box')).dimensions['size.x'], 350, 'the figure\'s 400 less 50')
541
+ assert.equal(asked, 0, 'nothing asked')
504
542
  })
505
543
 
506
544
  test('grown by one ratio, every part is made to follow it in one step, and the offer goes', async () => {
@@ -518,8 +556,15 @@ describe('a figure made in the modeller is given its size from its parts (user 2
518
556
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
519
557
  }))
520
558
  }
559
+ /* Back to the whole figure: a key in a field is the field's, so the field typed in is left first, then Esc. */
560
+ await page().evaluate(() => {
561
+ let focused = document.activeElement
562
+ while (focused?.shadowRoot?.activeElement) focused = focused.shadowRoot.activeElement
563
+ focused?.blur()
564
+ })
521
565
  await page().keyboard.press('Escape')
522
566
  await sleep(300)
567
+ assert.equal((await state()).selected, '', 'nothing picked')
523
568
  await press('figure.button.scale-uniformly')
524
569
  assert.equal((await state()).error, '', 'grows by one ratio')
525
570
  const start = await state()
@@ -615,9 +660,15 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
615
660
  assert.equal(await resize.evaluate(b => b.getAttribute('aria-pressed')), 'false')
616
661
  })
617
662
 
618
- test('a height typed into its field keeps the bottom on the floor, as the gizmo does', async () => {
663
+ test('a height typed into its field keeps the part\'s middle, and the size group says so (ADR-0140 4.1)', async () => {
619
664
  await mount('box')
620
665
  await click(await read(() => probe.partOnPage('box')))
666
+ const was = await body('box')
667
+ const middleOf = b => b.t[1] + b.dimensions.start + b.dimensions.length / 2
668
+ assert.ok(await read(() => {
669
+ const deep = (root = document) => [...root.querySelectorAll('p[size-keeps]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
670
+ return deep().some(p => p.textContent.trim() === 'figure.text.size-keeps-centre')
671
+ }), 'said beside the size fields')
621
672
  const height = await find('input', 'box.size.y')
622
673
  assert.ok(await height.evaluate(i => !!i), 'the height field')
623
674
  const at = await centre(height)
@@ -626,8 +677,8 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
626
677
  await page().keyboard.press('Enter')
627
678
  await sleep(600)
628
679
  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}`)
680
+ /* The box is drawn along y from its start: its middle is its origin plus the start plus half the length. */
681
+ assert.equal(Math.round(middleOf(now)), Math.round(middleOf(was)), `its middle where it was: ${middleOf(was)} -> ${middleOf(now)}`)
631
682
  assert.equal(now.dimensions.length, 1000)
632
683
  })
633
684
 
@@ -895,14 +946,14 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
895
946
 
896
947
  describe('the row section opens on one click', () => {
897
948
  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')))
949
+ await mount('shelf')
950
+ await click(await read(() => probe.row('slat')))
900
951
  await tab('response')
901
952
  const summary = await page().evaluateHandle(() => {
902
953
  const deep = (root = document) => [...root.querySelectorAll('details[row-fold] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
903
954
  return deep()[0] ?? null
904
955
  })
905
- assert.ok(await summary.evaluate(s => !!s && !!s.parentElement.querySelector('button[act]')), 'the mark is offered a row to make')
956
+ assert.ok(await summary.evaluate(s => !!s && !!s.parentElement.querySelector('button[act]')), 'the slat is offered a row to make')
906
957
  const at = await summary.evaluate(s => {
907
958
  s.scrollIntoView({ block: 'center' })
908
959
  const r = s.getBoundingClientRect()
@@ -915,9 +966,9 @@ describe('the row section opens on one click', () => {
915
966
  })
916
967
 
917
968
  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')))
969
+ await mount('shelf')
970
+ await click(await read(() => probe.row('slat')))
971
+ await click(await read(() => probe.row('bed')))
921
972
  const open = await read(async () => {
922
973
  const deep = (selector, root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(selector, e.shadowRoot)))
923
974
  const editor = deep('figure-editor')[0]
@@ -925,7 +976,7 @@ describe('the row section opens on one click', () => {
925
976
  The panel is drawn for the part once the editor and its links have updated; a click can come any time after
926
977
  that, before a frame is drawn. Here it comes right then.
927
978
  */
928
- editor.selected = 'mark'
979
+ editor.selected = 'slat'
929
980
  await editor.updateComplete
930
981
  await Promise.all(deep('figure-links').map(links => links.updateComplete))
931
982
  await null
@@ -141,6 +141,55 @@ describe('the parts list: each button does what it says, one step of the session
141
141
  })
142
142
  })
143
143
 
144
+ /*
145
+ Copy and paste with the canvas focused (user 2026-10-21): left to the scene, Ctrl+C / Ctrl+V copied the canvas object
146
+ with the part's name -- two objects of one name, the copy moving the original and never a part of its own.
147
+ */
148
+ describe('copy and paste on the canvas: a part of its own, named by figure-model', () => {
149
+ before(() => mount('box', 'three'))
150
+
151
+ const withControl = async code => {
152
+ await page().keyboard.down('Control')
153
+ await page().keyboard.press(code)
154
+ await page().keyboard.up('Control')
155
+ await sleep(400)
156
+ }
157
+
158
+ test('Ctrl+C then Ctrl+V on the canvas: one step, a new row with a name of its own, one canvas object per name', async () => {
159
+ await click(await read(() => probe.partOnPage('b')), 'b on the canvas')
160
+ assert.equal((await state()).selected, 'b', 'picked on the canvas, the canvas focused')
161
+ const was = await ids()
162
+ const step = await revision()
163
+ await withControl('KeyC')
164
+ await withControl('KeyV')
165
+ const now = await ids()
166
+ const made = now.filter(id => !was.includes(id))
167
+ assert.equal(made.length, 1, `one new part: ${now}`)
168
+ assert.notEqual(made[0], 'b')
169
+ assert.equal(await revision(), step + 1, 'one step')
170
+ assert.equal((await state()).selected, made[0], 'the copy is the part selected')
171
+ const drawn = await read(() => parts.drawn())
172
+ assert.equal(new Set(drawn).size, drawn.length, `no two canvas objects of one name: ${drawn}`)
173
+ })
174
+
175
+ test('Ctrl+X and Ctrl+Shift+V are kept from the scene: nothing cut or pasted on the canvas alone', async () => {
176
+ await click(await read(() => probe.partOnPage('b')), 'b on the canvas')
177
+ const was = await ids()
178
+ const step = await revision()
179
+ await withControl('KeyX')
180
+ await page().keyboard.down('Control')
181
+ await page().keyboard.down('Shift')
182
+ await page().keyboard.press('KeyV')
183
+ await page().keyboard.up('Shift')
184
+ await page().keyboard.up('Control')
185
+ await sleep(400)
186
+ assert.deepEqual(await ids(), was)
187
+ assert.equal(await revision(), step)
188
+ const drawn = await read(() => parts.drawn())
189
+ assert.deepEqual([...drawn].sort(), [...was].sort(), 'the canvas draws the figure\'s parts, no more, no fewer')
190
+ })
191
+ })
192
+
144
193
  describe('hidden on the workbench: off the canvas, not out of the figure, and let go when another figure opens', () => {
145
194
  before(() => mount('box', 'three'))
146
195
 
@@ -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 () => {
@@ -330,6 +330,33 @@ describe('the section editor on a box whose sizes follow the figure', () => {
330
330
  })
331
331
  })
332
332
 
333
+ /*
334
+ * The user's case (2026-10-21, 「수정본 검증 - 기본 버튼 상자」): 「속 공간 만들기」 ticked on a box whose sizes follow the
335
+ * figure's was refused, for a 1 mm the figure had never been asked to go to. Now it is one step and nothing is refused:
336
+ * how small the figure can then be is computed from the walls (ADR-0087 decision 6).
337
+ */
338
+ describe('the inside made on a box whose sizes follow the figure', () => {
339
+ before(async () => {
340
+ await mount('follows')
341
+ await pick('box')
342
+ })
343
+
344
+ test('ticked, it is one step, drawn, and not refused', async () => {
345
+ const start = await state()
346
+ /* Clicked where it is drawn, as a person does. */
347
+ const at = await (await element('label[toggle] input', { within: 'figure-section-panel' })).evaluate(e => {
348
+ e.scrollIntoView({ block: 'center' })
349
+ const r = e.getBoundingClientRect()
350
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
351
+ })
352
+ await page().mouse.click(at.x, at.y)
353
+ await until(r => probe.state().revision === r + 1, 'one step', start.revision)
354
+ assert.equal((await state()).error, '', 'not refused')
355
+ await until(() => probe.sectionPanel()?.cavityDrawing, 'the inside drawn')
356
+ assert.ok((await held('box')).cavity, 'the box has its inside')
357
+ })
358
+ })
359
+
333
360
  describe('a drawn section\'s end drawn to one point', () => {
334
361
  test('turned on, the length is called the height', async () => {
335
362
  await mount('wedge')
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
9
9
  import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
- import { aimFasten, fastenChanged, fastenDetailWordsOf, fastenFormOf, fastenPlanOf, fastenSentenceOf, fastenTargetsOf, freshFasten } from '../client/modeller/fasten-form.ts'
12
+ import { aimFasten, aimFastenAt, fastenChanged, fastenDetailWordsOf, fastenFormOf, fastenPlanOf, fastenSentenceOf, fastenTargetsOf, freshFasten, keptGapOf } from '../client/modeller/fasten-form.ts'
13
13
  import { revisionOf } from './revision-of.ts'
14
14
 
15
15
  const box = (asset: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
@@ -94,6 +94,46 @@ test('the drawing\'s changes: another face aims again, the way, the gap, a place
94
94
  assert.equal(fastenPlanOf(r, 'roller', f).targetFace, 'front')
95
95
  })
96
96
 
97
+ /*
98
+ * 「현재 상태로 붙이기」(ADR-0140 stage 4, user 2026-10-20): aimed at a face, the gap starts at the distance the part
99
+ * stands from it, so fastening does not move it. Changing how it meets the face works it out again; a number typed is
100
+ * the author's and stays; a press on the canvas puts the part there, with no gap.
101
+ */
102
+ const centreOf = (asset: FigureAsset, id: string) => (compileFigureAsset(asset).evaluate().geometry as Array<{ id: string; pose?: { t: number[] } }>).find(g => g.id === id)!.pose!.t
103
+ /* A plate on the floor, its top at 20 mm, and a lid 40 mm above it. */
104
+ function floating() {
105
+ let a = fasten(box(createFigureAsset('Lift'), 'plate', [600, 20, 600], [0, 10, 0]), 'plate', 'plane')
106
+ return revisionOf(box(a, 'lid', [100, 20, 100], [0, 70, 0]))
107
+ }
108
+
109
+ test('aimed at a face, the gap is where the part stands, and fastening it does not move it', () => {
110
+ const r = floating()
111
+ const f = keptGapOf(r, 'lid', aimFasten(r, 'lid', fastenFormOf(r, 'lid'), { part: 'plate' }))
112
+ assert.deepEqual([f.gap, f.gapFrom], [40, 'kept'])
113
+ const applied = applyFigureAuthoring(r.asset, fastenPlanOf(r, 'lid', f).action!)
114
+ assert.deepEqual(centreOf(applied, 'lid'), centreOf(r.asset, 'lid'), 'the lid where it was')
115
+ const onPlane = keptGapOf(r, 'lid', aimFasten(r, 'lid', f, { plane: true }))
116
+ assert.equal(onPlane.gap, 60, 'aimed at the floor instead: 60 mm above it')
117
+ })
118
+
119
+ test('another way of meeting works the gap out again; a number typed stays; a press on the canvas puts it there', () => {
120
+ const r = floating()
121
+ const aimed = keptGapOf(r, 'lid', aimFasten(r, 'lid', fastenFormOf(r, 'lid'), { part: 'plate' }))
122
+ const flush = fastenChanged(r, 'lid', aimed, { mode: 'flush' })
123
+ const applied = applyFigureAuthoring(r.asset, fastenPlanOf(r, 'lid', flush).action!)
124
+ assert.deepEqual(centreOf(applied, 'lid'), centreOf(r.asset, 'lid'), 'flush, and still where it was')
125
+ assert.deepEqual(fastenDetailWordsOf(aimed, say), [], 'the gap it stands at is the default: nothing said on the fold')
126
+ const typed = fastenChanged(r, 'lid', aimed, { gap: 5 })
127
+ assert.equal(typed.gapFrom, 'typed')
128
+ assert.deepEqual(fastenDetailWordsOf(typed, say), ['figure.text.fasten-summary-gap(mm=5)'])
129
+ assert.equal(fastenChanged(r, 'lid', typed, { mode: 'flush' }).gap, 5, 'the author\'s number is left')
130
+ assert.equal(keptGapOf(r, 'lid', aimFasten(r, 'lid', typed, { plane: true })).gap, 5)
131
+ const plate = (r.geometry as Array<{ id: string }>).find(g => g.id === 'plate')!.id
132
+ const pressed = aimFastenAt(r, 'lid', aimed, { id: plate, point: [0, 20, 0], normal: [0, 1, 0] }).form!
133
+ assert.deepEqual([pressed.gap, pressed.gapFrom], [0, 'pressed'])
134
+ assert.equal(fastenChanged(r, 'lid', pressed, { mode: 'flush' }).gap, 0, 'pressed: left at 0')
135
+ })
136
+
97
137
  test('the target list: every other part, the one hanging from this part listed as a loop', () => {
98
138
  const r = conveyor()
99
139
  assert.deepEqual(fastenTargetsOf(r, 'frame'), [
@@ -102,3 +142,64 @@ test('the target list: every other part, the one hanging from this part listed a
102
142
  ])
103
143
  assert.deepEqual(fastenTargetsOf(r, 'lid').map(t => t.part), ['frame', 'roller'])
104
144
  })
145
+
146
+ /* A drum lying on the floor along Y and a ball, with a tag standing beside each (ADR-0140 3.9). */
147
+ async function curved() {
148
+ const { addFigurePrimitive } = await import('@hatiolab/figure-model')
149
+ let a = addFigurePrimitive(createFigureAsset('Curved'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 1600 }, position: { x: 0, y: 0, z: 0 } } as never)
150
+ a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radius: 100, radiusY: 100 }, position: { x: 0, y: 0, z: 3000 } } as never)
151
+ a = box(a, 'tag', [40, 40, 40], [600, 600, 600])
152
+ return box(a, 'cap', [40, 40, 40], [0, 600, 3000])
153
+ }
154
+
155
+ test('aimed at a cylinder it stands beside, it goes on the side at the point it faces; aimed at a sphere, on its round', async () => {
156
+ const r = revisionOf(await curved())
157
+ const drum = aimFasten(r, 'tag', freshFasten(), { part: 'drum' })
158
+ assert.deepEqual(drum.surface, { around: 315, along: { mm: 600 } }, 'beside it, 600 up its length, toward +X +Z')
159
+ const plan = fastenPlanOf(r, 'tag', drum)
160
+ assert.deepEqual((plan.action as { to: unknown }).to, { part: 'drum', surface: { around: 315, along: { mm: 600 } } })
161
+ assert.equal((plan.action as { align?: unknown }).align, undefined, 'on a surface, where is its own words')
162
+ assert.deepEqual(plan.surface, { form: 'around', axis: 'y', place: { around: 315, along: { mm: 600 } } })
163
+ assert.equal(fastenSentenceOf(drum, plan, '바닥', say), 'figure.text.fasten-sentence-meet(to=drum의 figure.label.surface-around())')
164
+ const ball = aimFasten(r, 'cap', freshFasten(), { part: 'ball' })
165
+ assert.deepEqual(ball.surface, { toward: { x: 0, y: 1, z: 0 } }, 'straight above it')
166
+ /* Applied, and opened again: on the surface as it was. */
167
+ const done = applyFigureAuthoring(r.asset, plan.action!)
168
+ assert.deepEqual(fastenFormOf(revisionOf(done), 'tag').surface, { around: 315, along: { mm: 600 } })
169
+ const { rowBlockOf } = await import('../client/modeller/links.ts')
170
+ assert.equal(rowBlockOf(done, 'tag'), 'on-surface', 'a point of a curved surface has no face to lay a row across')
171
+ })
172
+
173
+ test('a face or the surface, as the drawing asks; the surface chosen starts where the part stands', async () => {
174
+ const r = revisionOf(await curved())
175
+ const side = aimFasten(r, 'tag', freshFasten(), { part: 'drum' })
176
+ const top = fastenChanged(r, 'tag', side, { targetFace: 'top' })
177
+ assert.equal(top.surface, undefined, 'its end disc is a face')
178
+ assert.deepEqual((fastenPlanOf(r, 'tag', top).action as { to: unknown }).to, { part: 'drum', face: 'top' })
179
+ const back = fastenChanged(r, 'tag', top, { onSurface: true })
180
+ assert.deepEqual(back.surface, { around: 315, along: { mm: 600 } })
181
+ const turned = fastenChanged(r, 'tag', back, { surface: { around: 90, along: 'max' } })
182
+ assert.deepEqual((fastenPlanOf(r, 'tag', turned).action as { to: unknown }).to, { part: 'drum', surface: { around: 90, along: 'max' } })
183
+ })
184
+
185
+ test("pressed on the canvas: the face or the point pressed, read in that part's own frame; itself and a curve with no fastening said (ADR-0140 stage 4)", async () => {
186
+ const { addFigurePrimitive } = await import('@hatiolab/figure-model')
187
+ /* A drum lying along X (turned a quarter about Z), a rounded box, and the tag to fasten. */
188
+ let a = addFigurePrimitive(createFigureAsset('Pressed'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 500, z: 0 }, rotation: { x: 0, y: 0, z: 90 } } as never)
189
+ a = addFigurePrimitive(a, { id: 'soft', kind: 'rounded-box', dimensions: { width: 300, height: 200, depth: 300, round: 40 }, position: { x: 0, y: 100, z: 1500 } } as never)
190
+ const r = revisionOf(box(a, 'tag', [40, 40, 40], [900, 900, 900]))
191
+ const pose = (r.geometry as Array<{ id: string; pose: { r: number[][]; t: number[] } }>).find(g => g.id === 'drum')!.pose
192
+ const world = (v: number[], move = true) => [0, 1, 2].map(i => [0, 1, 2].reduce((s, c) => s + pose.r[i]![c]! * v[c]!, move ? pose.t[i]! : 0))
193
+ const rad = (d: number) => (d * Math.PI) / 180
194
+ const at = (deg: number) => [Math.cos(rad(deg)), 0, -Math.sin(rad(deg))]
195
+ /* A 12-sided drawing: the facet between 0° and 30°, pressed where the ray at 20° meets it, 50 up its own Y. */
196
+ const s = (100 * Math.cos(rad(15))) / Math.cos(rad(5))
197
+ const press = { id: 'drum', point: world(at(20).map((v, i) => (i === 1 ? 50 : v * s))), normal: world(at(15), false) }
198
+ const side = aimFastenAt(r, 'tag', freshFasten(), press)
199
+ assert.deepEqual([side.note, side.form?.target, side.form?.surface], [undefined, 'drum', { around: 20, along: { mm: 50 } }])
200
+ const end = aimFastenAt(r, 'tag', freshFasten(), { id: 'drum', point: world([10, 200, 20]), normal: world([0, 1, 0], false) })
201
+ assert.deepEqual([end.form?.surface, end.form?.targetFace, end.form?.align, end.form?.alignMm], [undefined, 'top', { x: 'mm', z: 'mm' }, { x: 10, z: 20 }], "its end, wherever the drum's own Y lies, its middle where pressed")
202
+ assert.deepEqual(aimFastenAt(r, 'tag', freshFasten(), { id: 'tag', point: [900, 920, 900], normal: [0, 1, 0] }), { note: 'aim-self', part: 'tag' })
203
+ const corner = aimFastenAt(r, 'tag', freshFasten(), { id: 'soft', point: [145, 100, 1645], normal: [Math.SQRT1_2, 0, Math.SQRT1_2] })
204
+ assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
205
+ })
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'
11
11
 
12
12
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset, createFigureEditorWorkspace } from '@hatiolab/figure-model'
13
13
 
14
- import { gizmoChanges, gizmoReachOf } from '../client/modeller/gizmo.ts'
14
+ import { gizmoChanges, gizmoReachOf, sameMeasureAxesOf } from '../client/modeller/gizmo.ts'
15
15
  import { moving } from './joints.ts'
16
16
 
17
17
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
@@ -127,33 +127,31 @@ test('원기둥 · 구도 크기 기즈모가 먹는다 — 단면은 원으로'
127
127
  })
128
128
 
129
129
  /*
130
- * 높이를 숫자로 적거나 손잡이로 키워도, 도형의 배치가 지키는 면이 그 자리에 있다 — figure-model 의 한 규칙
131
- * (`figureSizeEditChanges`, ADR-0097). 전에는 화면이 제 산수로 배치와 상관없이 아랫면을 지켰다. 2200 을 적은 기둥이
132
- * 바닥 아래로 933 mm 묻혔던 일(09-26)은 바닥 도형에서 그대로 막힌다. 돌려 세운 부품은 그 높이가 위아래가 아니라 건드리지 않는다.
130
+ * 크기는 커널의 한 함수로 바뀐다(ADR-0140 4.1 · 4.2, `figureSizeEditOf`): 숫자 칸은 부품의 가운데를, 손잡이는 잡은
131
+ * 쪽의 반대편을 그 자리에 둔다 — 배치(바닥 · 천장)와 상관없이, 누운 원기둥이든 서 있는 기둥이든. 같은 치수 · 같은
132
+ * 기준이면 칸과 손잡이가 같은 결과다.
133
133
  */
134
- test('크기 칸과 손잡이 — 배치가 지키는 면을 지킨다, 커널의 한 규칙으로', async () => {
135
- const { figureSizeEditChanges, applyFigureAuthoring, proposeFigureOccupancy } = await import('@hatiolab/figure-model')
134
+ test('크기 칸은 가운데를, 손잡이는 반대쪽을 — 배치와 상관없이 커널의 한 함수로', async () => {
135
+ const { figureSizeEditOf, applyFigureAuthoring } = await import('@hatiolab/figure-model')
136
136
  let asset = createFigureAsset('Post')
137
137
  asset = addFigureBox(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
138
- asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 300, z: 0 } })
139
138
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 500 }, position: { x: 0, y: 30, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
140
139
  const centreY = (a: typeof asset, part: string) => {
141
140
  const g = compileFigureAsset(a).evaluate().geometry.find(o => o.id === part)! as unknown as { pose: { t: number[] } }
142
141
  return g.pose.t[1]!
143
142
  }
144
143
  const set = (input: string, value: number) => ({ kind: 'set-design', input, unit: 'mm', value }) as never
145
- const grow = (a: typeof asset, part: string, input: string, value: number) => apply(a, figureSizeEditChanges(a, part, [set(input, value)]))
144
+ const grow = (a: typeof asset, part: string, input: string, value: number, keep: never) => apply(a, figureSizeEditOf(a, part, [set(input, value)], keep).changes)
146
145
  const postH = gizmoReachOf(asset, 'post').sizes![1]
147
- assert.equal(centreY(grow(asset, 'post', postH, 2200), 'post') - 2200 / 2, 0, '바닥 도형(선언 전 기본): 기둥 아랫면이 바닥에')
148
- const drumH = gizmoReachOf(asset, 'drum').round!.along[0]!
149
- assert.equal(centreY(grow(asset, 'drum', drumH, 300), 'drum') - 300 / 2, 300 - 100 / 2, '원기둥도 아랫면이 제자리')
146
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'centre' } as never), 'post'), 50, '숫자 칸: 가운데가 그 자리')
147
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'opposite', axis: 'y', side: 1 } as never), 'post') - 2200 / 2, 0, '위 손잡이: 아랫면이 그 자리')
148
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'opposite', axis: 'y', side: -1 } as never), 'post') + 2200 / 2, 100, '아래 손잡이: 윗면이 그 자리')
150
149
  const rollerLength = gizmoReachOf(asset, 'roller').round!.along[0]!
151
- assert.deepEqual(figureSizeEditChanges(asset, 'roller', [set(rollerLength, 800)]), [set(rollerLength, 800)], '누운 롤러의 길이는 위아래가 아니다')
152
- assert.deepEqual(figureSizeEditChanges(asset, 'post', [set(gizmoReachOf(asset, 'post').sizes![0], 800)]), [set(gizmoReachOf(asset, 'post').sizes![0], 800)], '폭은 가운데 그대로')
153
- /* 천장 도형이면 윗면을 지킨다 — 전에는 여기서도 아랫면을 지켜 천장을 뚫고 올라갔다. */
150
+ assert.deepEqual(figureSizeEditOf(asset, 'roller', [set(rollerLength, 800)], { keep: 'centre' }).changes, [set(rollerLength, 800)], '누운 롤러의 길이: 가운데가 원점이라 자리는 그대로')
151
+ /* 천장 도형도 같다 — 배치가 지키는 면을 따로 두지 않는다(ADR-0140 4.1 이 ADR-0097 §4.3 을 대신함). */
154
152
  let hung = addFigureBox(createFigureAsset('Lamp'), { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: -50, z: 0 } })
155
- hung = applyFigureAuthoring(hung, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(hung), placement: 'ceiling' })
156
- assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300), 'lamp') + 300 / 2, 0, '천장 도형: 윗면이 천장에')
153
+ hung = applyFigureAuthoring(hung, { kind: 'set-placement', placement: 'ceiling' })
154
+ assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300, { keep: 'centre' } as never), 'lamp'), -50, '천장 도형: 숫자 칸도 가운데')
157
155
  })
158
156
 
159
157
  /* 손잡이는 키를 몰라도 찾는다 — 도구 막대에 옮기기 · 돌리기 · 크기, 제 숫자로 놓인 부품에만 (09-26). */
@@ -161,3 +159,14 @@ test('도구 막대의 옮기기 · 돌리기 · 크기 — 키와 같은 길,
161
159
  const ko = JSON.parse(read('../translations/ko.json'))
162
160
  assert.deepEqual(['translate', 'rotate', 'scale'].map(m => ko['figure.button.gizmo-' + m]), ['옮기기', '돌리기', '크기 바꾸기'])
163
161
  })
162
+
163
+ /* ADR-0140 4.1: the size handles that change one measure are said while the size tool is on. */
164
+ test('같은 치수 — 원기둥의 X · Z 핸들은 한 지름, 구는 셋이 하나, 상자는 따로', () => {
165
+ const at = { x: 0, y: 0, z: 0 }
166
+ const drum = addFigurePrimitive(createFigureAsset('Drum'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 200 }, position: at })
167
+ const ball = addFigurePrimitive(createFigureAsset('Ball'), { id: 'ball', kind: 'sphere', dimensions: { radius: 50, radiusY: 50 }, position: at })
168
+ const crate = addFigureBox(createFigureAsset('Crate'), { id: 'crate', size: { x: 100, y: 100, z: 100 }, position: at })
169
+ assert.deepEqual(sameMeasureAxesOf(gizmoReachOf(drum, 'drum')), ['x', 'z'])
170
+ assert.deepEqual(sameMeasureAxesOf(gizmoReachOf(ball, 'ball')), ['x', 'y', 'z'])
171
+ assert.equal(sameMeasureAxesOf(gizmoReachOf(crate, 'crate')), undefined)
172
+ })
package/test/keys.test.ts CHANGED
@@ -41,3 +41,14 @@ test('캔버스에서 누른 W · E · R 은 씬이 이미 받았다 — 또 넘
41
41
  for (const key of ['w', 'e', 'r']) assert.equal(keyActionOf({ key, inScene: true }, 'arm'), null, key)
42
42
  assert.equal(keyActionOf({ key: 'Delete', inScene: true }, 'arm'), 'remove', '다른 키는 그대로')
43
43
  })
44
+
45
+ /* Ctrl+C · Ctrl+V are the modeller's: the scene's copied the canvas object with the part's name (user 2026-10-21). */
46
+ test('복사 · 붙여넣기는 모델러의 것 — 고른 부품의 Ctrl+C 는 복사, Ctrl+V 는 붙여넣기, 씬의 잘라내기 · 여러 개 붙여넣기는 막는다', () => {
47
+ assert.equal(keyActionOf({ key: 'c', ctrlKey: true }, 'arm'), 'copy')
48
+ assert.equal(keyActionOf({ key: 'C', metaKey: true }, 'arm'), 'copy')
49
+ assert.equal(keyActionOf({ key: 'c', ctrlKey: true }, ''), 'hold', '고른 것이 없어도 씬에 넘기지 않는다')
50
+ assert.equal(keyActionOf({ key: 'v', ctrlKey: true }, ''), 'paste', '고른 것이 없어도 붙여넣기')
51
+ assert.equal(keyActionOf({ key: 'V', ctrlKey: true, shiftKey: true }, 'arm'), 'hold')
52
+ assert.equal(keyActionOf({ key: 'x', ctrlKey: true }, 'arm'), 'hold')
53
+ assert.equal(keyActionOf({ key: 'v', ctrlKey: true, target: { tagName: 'INPUT' } }, ''), null, '칸 안의 붙여넣기는 칸의 것')
54
+ })