@things-factory/figure-ui 10.1.205 → 10.1.207

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 (105) hide show
  1. package/client/modeller/editor-styles.ts +16 -0
  2. package/client/modeller/editor.ts +53 -137
  3. package/client/modeller/fasten-form.ts +50 -11
  4. package/client/modeller/figure-facts.ts +1 -1
  5. package/client/modeller/figure-fasten-dialog.ts +3 -2
  6. package/client/modeller/figure-history-panel.ts +1 -29
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +10 -12
  9. package/client/modeller/figure-verdict.ts +2 -17
  10. package/client/modeller/gizmo.ts +10 -0
  11. package/client/modeller/keys.ts +10 -1
  12. package/client/modeller/links.ts +32 -41
  13. package/client/modeller/maturity.ts +11 -27
  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 +20 -33
  18. package/client/pages/figure-modeller-page.ts +7 -22
  19. package/dist-client/modeller/editor-styles.js +16 -0
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +8 -11
  22. package/dist-client/modeller/editor.js +57 -138
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +16 -7
  25. package/dist-client/modeller/fasten-form.js +45 -11
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-facts.js +1 -1
  28. package/dist-client/modeller/figure-facts.js.map +1 -1
  29. package/dist-client/modeller/figure-fasten-dialog.js +3 -3
  30. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  31. package/dist-client/modeller/figure-history-panel.d.ts +0 -16
  32. package/dist-client/modeller/figure-history-panel.js +1 -27
  33. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  34. package/dist-client/modeller/figure-links.js +2 -2
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/figure-preview.js +8 -11
  37. package/dist-client/modeller/figure-preview.js.map +1 -1
  38. package/dist-client/modeller/figure-verdict.d.ts +0 -5
  39. package/dist-client/modeller/figure-verdict.js +2 -21
  40. package/dist-client/modeller/figure-verdict.js.map +1 -1
  41. package/dist-client/modeller/gizmo.d.ts +6 -0
  42. package/dist-client/modeller/gizmo.js +11 -0
  43. package/dist-client/modeller/gizmo.js.map +1 -1
  44. package/dist-client/modeller/keys.d.ts +1 -1
  45. package/dist-client/modeller/keys.js +6 -0
  46. package/dist-client/modeller/keys.js.map +1 -1
  47. package/dist-client/modeller/links.d.ts +16 -22
  48. package/dist-client/modeller/links.js +26 -39
  49. package/dist-client/modeller/links.js.map +1 -1
  50. package/dist-client/modeller/maturity.d.ts +2 -4
  51. package/dist-client/modeller/maturity.js +12 -26
  52. package/dist-client/modeller/maturity.js.map +1 -1
  53. package/dist-client/modeller/palette.js +1 -1
  54. package/dist-client/modeller/palette.js.map +1 -1
  55. package/dist-client/modeller/product-session.d.ts +15 -13
  56. package/dist-client/modeller/product-session.js +15 -5
  57. package/dist-client/modeller/product-session.js.map +1 -1
  58. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  59. package/dist-client/modeller/refusal-teller.js +3 -22
  60. package/dist-client/modeller/refusal-teller.js.map +1 -1
  61. package/dist-client/modeller/refusals.d.ts +7 -17
  62. package/dist-client/modeller/refusals.js +17 -34
  63. package/dist-client/modeller/refusals.js.map +1 -1
  64. package/dist-client/pages/figure-modeller-page.d.ts +1 -3
  65. package/dist-client/pages/figure-modeller-page.js +7 -23
  66. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  67. package/dist-client/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +7 -7
  69. package/test/browser/acceptance-page.js +4 -4
  70. package/test/browser/detail-page.js +1 -3
  71. package/test/browser/detail-tabs-page.js +1 -2
  72. package/test/browser/links.check.mjs +32 -50
  73. package/test/browser/modeller.check.mjs +46 -19
  74. package/test/browser/parts-page.js +2 -2
  75. package/test/browser/parts.check.mjs +51 -12
  76. package/test/browser/product-page.check.mjs +6 -9
  77. package/test/browser/product-page.js +1 -2
  78. package/test/browser/screen-page.js +2 -6
  79. package/test/browser/screen.check.mjs +8 -65
  80. package/test/browser/section.check.mjs +27 -0
  81. package/test/fasten-form.test.ts +42 -2
  82. package/test/first-minute.test.ts +2 -3
  83. package/test/fixtures/joint-column.json +1 -66
  84. package/test/fixtures/joint-roller.json +1 -66
  85. package/test/fixtures/robot-arm-6dof.json +2 -67
  86. package/test/gizmo.test.ts +12 -1
  87. package/test/keys.test.ts +11 -0
  88. package/test/links.test.ts +39 -34
  89. package/test/parts-wiring.test.ts +1 -1
  90. package/test/properties.test.ts +9 -12
  91. package/test/refusals.test.ts +20 -42
  92. package/test/resize-with-placement.test.ts +13 -15
  93. package/test/revision.test.ts +1 -2
  94. package/test/verdict.test.ts +1 -15
  95. package/translations/en.json +8 -32
  96. package/translations/es.json +8 -32
  97. package/translations/ja.json +8 -32
  98. package/translations/ko.json +8 -32
  99. package/translations/ms.json +8 -32
  100. package/translations/zh.json +8 -32
  101. package/client/modeller/occupancy.ts +0 -51
  102. package/dist-client/modeller/occupancy.d.ts +0 -31
  103. package/dist-client/modeller/occupancy.js +0 -36
  104. package/dist-client/modeller/occupancy.js.map +0 -1
  105. package/test/release-panel.test.ts +0 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.205",
3
+ "version": "10.1.207",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.9.5",
30
+ "@hatiolab/figure-model": "^0.10.0",
31
31
  "@hatiolab/things-scene": "^10.1.95",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.43",
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.43",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.205",
46
- "@things-factory/figure-service": "^10.1.205",
47
- "@things-factory/figure-viewer": "^10.1.205",
48
- "@things-factory/setting-base": "^10.1.205",
45
+ "@things-factory/ai-assistant": "^10.1.207",
46
+ "@things-factory/figure-service": "^10.1.207",
47
+ "@things-factory/figure-viewer": "^10.1.207",
48
+ "@things-factory/setting-base": "^10.1.206",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
51
+ "gitHead": "e6e3fbf5c353629adbeb8850093150b14254e059"
52
52
  }
@@ -10,7 +10,7 @@ import 'figure-ui-client/modeller/figure-properties.js'
10
10
  import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
11
11
  import { runFigureProposal, takeFigureOffer } from 'figure-ui-client/modeller/ai-proposal.js'
12
12
  import { i18next } from '@operato/i18n'
13
- import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, editFigurePart, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
13
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
14
14
 
15
15
  const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
16
16
 
@@ -19,7 +19,7 @@ function box(placement, position) {
19
19
  let a = createFigureAsset(`Probe ${placement}`)
20
20
  a.palette = { ...a.palette, ...PALETTE }
21
21
  a = addFigureBox(a, { id: 'box', size: { x: 400, y: 400, z: 400 }, position })
22
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement }))
22
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement }))
23
23
  }
24
24
 
25
25
  function turned(rotation, placement = 'floor', position = { x: 0, y: 400, z: 0 }) {
@@ -91,7 +91,7 @@ export const MODELS = {
91
91
  a.palette = { ...a.palette, ...PALETTE }
92
92
  a = addFigureBox(a, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: -400, y: 350, z: 0 } })
93
93
  a = addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 720, z: 0 } })
94
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
94
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
95
95
  },
96
96
  /* A ceiling figure's part off its ceiling, for the one-off align (to the ceiling, by its top). */
97
97
  'ceiling-off': () => box('ceiling', { x: 0, y: -600, z: 0 }),
@@ -162,7 +162,7 @@ export const MODELS = {
162
162
  ball: () => {
163
163
  let a = createFigureAsset('Ball')
164
164
  a.palette = { ...a.palette, ...PALETTE }
165
- a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 200, radiusY: 200, radiusZ: 200 }, position: { x: 0, y: 500, z: 0 } })
165
+ a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radius: 200, radiusY: 200 }, position: { x: 0, y: 500, z: 0 } })
166
166
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
167
167
  },
168
168
  /* 500 mm above the floor: keep the gap, or be brought onto it. */
@@ -1,7 +1,7 @@
1
1
  /* Isolated product detail: fixtures are in memory, never saved to the running app. */
2
2
  import 'figure-ui-client/viewparts/figure-detail-dock.js'
3
3
  import { i18next } from '@operato/i18n'
4
- import { addFigureBox, createFigureAsset, editFigurePart, applyFigureAuthoring, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
4
+ import { addFigureBox, createFigureAsset, editFigurePart, applyFigureAuthoring, serializeFigureAsset } from '@hatiolab/figure-model'
5
5
 
6
6
  async function start() {
7
7
  i18next.addResourceBundle('ko', 'translations', await fetch('/ko.json').then(r => r.json()), true, true)
@@ -14,8 +14,6 @@ async function start() {
14
14
  id: 'block', type: 'prismatic', body0: { plane: 'mounting-plane' }, body1: { part: 'block' }, axis: 'X', lowerLimit: 0, upperLimit: 200, state: { id: 'shift', start: 0 }
15
15
  } })
16
16
  asset = editFigurePart(asset, { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 200 }, { seconds: 4, value: 0 }] })
17
- /* Its room declared from its parts over the whole motion, as the modeller does before a release. */
18
- asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }) })
19
17
  const source = serializeFigureAsset(asset)
20
18
  const dock = document.createElement('figure-detail-dock')
21
19
  let fail = true
@@ -4,7 +4,7 @@
4
4
  * are in memory; nothing is saved.
5
5
  */
6
6
  import 'figure-ui-client/viewparts/figure-detail-dock.js'
7
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, proposeFigureOccupancy, robotDogFigure, serializeFigureAsset } from '@hatiolab/figure-model'
7
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, robotDogFigure, serializeFigureAsset } from '@hatiolab/figure-model'
8
8
 
9
9
  const PARTS = 12
10
10
 
@@ -17,7 +17,6 @@ for (let i = 0; i < PARTS; i++) {
17
17
  } })
18
18
  asset = editFigurePart(asset, { kind: 'set-driver', state: `${id}-z`, clip: `cycle-${i}`, duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 1, value: 100 }, { seconds: 2, value: 0 }] })
19
19
  }
20
- asset = applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }) })
21
20
  const source = serializeFigureAsset(asset)
22
21
 
23
22
  const dock = document.createElement('figure-detail-dock')
@@ -17,7 +17,6 @@ import {
17
17
  applyFigureProposal,
18
18
  createFigureAsset,
19
19
  planFigureResize,
20
- proposeFigureOccupancy,
21
20
  serializeFigureAsset
22
21
  } from '@hatiolab/figure-model'
23
22
 
@@ -241,7 +240,7 @@ describe('a part made to follow the figure size, and let go of it', () => {
241
240
  })
242
241
  })
243
242
 
244
- /* A base and a flange, sized; the height may be placed from 90 mm though the parts measure down to 1. */
243
+ /* A base and a flange, sized, saved with ends on every axis: the height from 90 mm, the others from 1 mm. */
245
244
  function sizedPair(turning) {
246
245
  let a = box(createFigureAsset('Probe'), 'base', [200, 100, 200], [0, 50, 0])
247
246
  a = addFigurePrimitive(a, { id: 'flange', kind: 'cylinder', dimensions: { radius: 20, length: 10 }, position: { x: 0, y: 105, z: 0 } })
@@ -256,16 +255,11 @@ function sizedPair(turning) {
256
255
  }
257
256
 
258
257
  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 () => {
258
+ test('a figure saved with ends reads them as typed, and nothing is offered to measure again', async () => {
260
259
  await mount('sized-pair', sizedPair(false))
261
260
  /* The figure's range is the 「반응형」 tab's. */
262
261
  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')
262
+ assert.equal(await at('button', 'figure.button.remeasure-range'), null, 'nothing to measure again')
269
263
  })
270
264
 
271
265
  test('a figure with no joint is not urged to grow by one ratio; declared, the rule is said', async () => {
@@ -282,6 +276,26 @@ describe('the sizes a figure may be placed at, and how it grows', () => {
282
276
  })
283
277
  })
284
278
 
279
+ /*
280
+ A link that makes the height impossible below 95 mm is made, not refused (user 2026-10-21): the smallest height the
281
+ 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).
282
+ */
283
+ describe('a part change a narrowed end cannot hold', () => {
284
+ test('is one step; the end moves in, said in one line', async () => {
285
+ await mount('sized-pair-moved', sizedPair(false))
286
+ await pick('flange')
287
+ await read(() => look.hear('notify'))
288
+ const start = await state()
289
+ await read(() => look.tell('shape', 'command', { action: { kind: 'link-dimension', part: 'flange', axis: 'y', source: { of: 'instance', axis: 'y' }, plus: -95 } }))
290
+ await oneStep(start)
291
+ assert.equal((await state()).error, '', 'not refused')
292
+ const y = (await source()).document.model.inputs.find(i => i.id === 'size.y')
293
+ assert.ok(y.min > 95 && y.min < 95.001 && y.max === 1000, `the smallest height moved in: ${y.min}`)
294
+ const told = (await heard('notify')).filter(n => n?.level === 'info' && String(n.message).includes('figure.text.size-range-moved-min'))
295
+ assert.equal(told.length, 1, 'said once')
296
+ })
297
+ })
298
+
285
299
  /* A conveyor's rollers in a row across its frame, 200 mm apart. */
286
300
  function conveyor() {
287
301
  let a = fasten(box(createFigureAsset('Conveyor'), 'frame', [2000, 100, 600], [0, 50, 0]), 'frame', 'plane')
@@ -399,9 +413,11 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
399
413
  assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: false, role: 'dialog' })
400
414
  })
401
415
 
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 () => {
416
+ /* Chosen in the window, the gap is the distance the leaf stands at: it is fastened where it is (ADR-0140 stage 4, 「현재 상태로 붙이기」). */
417
+ 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 () => {
403
418
  await aimAt('frame')
404
- await until(() => look.drawn().includes('__preview:leaf') && look.drawn().includes('__target-face'), [], 'the leaf and the face drawn')
419
+ 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')
420
+ assert.deepEqual(await ghosts(), [], 'nothing drawn where it would go: it does not move')
405
421
  const told = (await heard('preview-command')).filter(Boolean).at(-1)
406
422
  assert.equal(told?.action.kind, 'attach')
407
423
  assert.equal((await source()).joints?.some(j => j.body1.part === 'leaf') ?? false, false, 'nothing written')
@@ -443,7 +459,7 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
443
459
 
444
460
  test('Esc closes the window and tells the editor to clear what it tried; the leaf stays selected', async () => {
445
461
  await aimAt('frame')
446
- await until(() => look.drawn().includes('__preview:leaf'), [], 'the leaf drawn where it would go')
462
+ await until(() => look.drawn().includes('__target-face'), [], 'the face it would meet drawn')
447
463
  await read(() => look.hear('preview-command', 'dialog'))
448
464
  await page().keyboard.press('Escape')
449
465
  await until(() => !probe.fastenTargets(), [], 'the window closed')
@@ -459,9 +475,9 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
459
475
  await aimAt('frame')
460
476
  /*
461
477
  The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
462
- aimed once the window has drawn what the choice brings -- the leaf where it would go -- or it lands where the face was.
478
+ aimed once the window has drawn what the choice brings -- the face it would meet -- or it lands where the face was.
463
479
  */
464
- await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
480
+ await until(() => look.face('front')?.pickable && look.drawn().includes('__target-face'), [], 'the front face offered, the face it would meet drawn')
465
481
  await settled()
466
482
  await sleep(200)
467
483
  await click(await at('path[face="front"]'), 'the front face in the drawing')
@@ -666,52 +682,18 @@ describe('a part fastened twisted is sized by a drag: its size alone, its fasten
666
682
  describe('a box made to follow the figure size on the floor', () => {
667
683
  before(async () => {
668
684
  let a = box(createFigureAsset('Floor'), 'box', [100, 100, 100], [20, 50, 30])
669
- a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
685
+ a = applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' })
670
686
  await mount('floor', applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps))
671
687
  await pick('box')
672
688
  })
673
689
 
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 () => {
675
- const roomOnly = await all('input[aria-label="box.position.y"]')
676
- assert.equal(roomOnly.length, 1, 'one field for it, apart from the position row')
677
- assert.equal(roomOnly[0].disabled, true, 'not typed into')
678
- assert.equal((await all('section[room-only] input[aria-label="box.position.y"]')).length, 1, 'in the room\'s section')
690
+ test('its row shows the number the floor gives; a size drag is drawn by the modeller', async () => {
679
691
  const held = (await all('div[vector-row] input[readonly]')).filter(i => i.label?.startsWith('position.y'))
680
692
  assert.deepEqual(held.map(i => i.value), ['50'], 'the height the floor gives, read-only')
681
693
  assert.equal(await read(() => look.scalePlacement('box')), 'owner', 'its place in a size drag is the modeller\'s (ADR-0140 4.4)')
682
694
  })
683
-
684
- test('the room is declared again only when the author confirms', async () => {
685
- const prompt = id =>
686
- read(id => {
687
- const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
688
- const button = deep().at(-1)?.shadowRoot?.querySelector(`#${id}`)
689
- if (!button) return null
690
- const r = button.getBoundingClientRect()
691
- return r.width ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null
692
- }, id)
693
- const start = await state()
694
- await click(await at('button[room-refit]'), 'declare the room again')
695
- await until(() => !!document.querySelector('ox-prompt'), [], 'asked')
696
- await click(await poll(() => prompt('cancel')), 'cancel')
697
- await until(() => !document.querySelector('ox-prompt'), [], 'the question gone')
698
- assert.equal((await state()).revision, start.revision, 'cancelled: nothing written')
699
- await click(await at('button[room-refit]'), 'declare the room again')
700
- await click(await poll(() => prompt('confirm')), 'confirm')
701
- await until(r => probe.state().revision === r + 1, [start.revision], 'confirmed: one step')
702
- })
703
695
  })
704
696
 
705
- /** Asks until the answer is something, for a few seconds. */
706
- async function poll(ask, ms = 5000) {
707
- const deadline = Date.now() + ms
708
- for (;;) {
709
- const answer = await ask()
710
- if (answer || Date.now() > deadline) return answer
711
- await sleep(50)
712
- }
713
- }
714
-
715
697
  /*
716
698
  * The fastening window writes once (user 2026-10-17): whatever is tried in it is a preview, 「적용」 is one step of the
717
699
  * history with all of it, and 「취소」 or Esc leave the history as it was.
@@ -452,8 +452,8 @@ describe('a figure made in the modeller is given its size from its parts (user 2
452
452
  assert.equal((await declared())['size.x'][1], 3000, 'the largest width is what was typed')
453
453
  await type('size.x.min', '500')
454
454
  assert.notEqual((await state()).error, '', 'the figure is 400 wide: a smallest of 500 is refused')
455
- assert.equal((await declared())['size.x'][0] < 400, true, 'and the range is as it was')
456
- 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')
457
457
  })
458
458
 
459
459
  /*
@@ -467,6 +467,34 @@ describe('a figure made in the modeller is given its size from its parts (user 2
467
467
  assert.notEqual(said, 'figure.text.edit-refused', `said in its own words: ${said}`)
468
468
  })
469
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
+
470
498
  test('a part follows the figure\'s width less a difference: chosen and typed, the width is the size less it', async () => {
471
499
  await click(await read(() => probe.partOnPage('box')))
472
500
  assert.equal((await state()).selected, 'box')
@@ -500,23 +528,17 @@ describe('a figure made in the modeller is given its size from its parts (user 2
500
528
  await page().keyboard.type('-50')
501
529
  await page().keyboard.press('Tab')
502
530
  await sleep(600)
503
- /* 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. */
504
- assert.match((await state()).error, /figure.text.link-needs-smaller-range/, 'refused, said in words')
505
- /* 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')
506
537
  const asked = await read(() => {
507
538
  const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
508
- const prompt = deep().at(-1)
509
- const confirm = prompt?.shadowRoot?.querySelector('#confirm')
510
- if (!confirm) return null
511
- const r = confirm.getBoundingClientRect()
512
- return { text: prompt.text, label: confirm.textContent.trim(), x: r.left + r.width / 2, y: r.top + r.height / 2 }
539
+ return deep().length
513
540
  })
514
- assert.match(asked?.text ?? '', /figure.text.link-needs-smaller-range/, 'asked in the app\'s prompt')
515
- assert.match(asked.label, /figure.button.narrow-range-from/, 'the same link is offered with the sizes narrowed')
516
- await click(asked)
517
- await sleep(600)
518
- assert.equal((await state()).error, '', 'taken')
519
- assert.equal((await body('box')).dimensions['size.x'], 350, 'the figure\'s 400 less 50')
541
+ assert.equal(asked, 0, 'nothing asked')
520
542
  })
521
543
 
522
544
  test('grown by one ratio, every part is made to follow it in one step, and the offer goes', async () => {
@@ -534,8 +556,15 @@ describe('a figure made in the modeller is given its size from its parts (user 2
534
556
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
535
557
  }))
536
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
+ })
537
565
  await page().keyboard.press('Escape')
538
566
  await sleep(300)
567
+ assert.equal((await state()).selected, '', 'nothing picked')
539
568
  await press('figure.button.scale-uniformly')
540
569
  assert.equal((await state()).error, '', 'grows by one ratio')
541
570
  const start = await state()
@@ -740,9 +769,7 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
740
769
  assert.equal(after.revision, start.revision + 1, 'one step')
741
770
  assert.deepEqual(await read(() => probe.fields('size.')), { 'size.x': 400, 'size.y': 400, 'size.z': 400 }, 'the size is the box as it stands')
742
771
  const notice = await read(() => (window.__told ?? []).filter(n => n.level === 'info').at(-1)?.message ?? null)
743
- /* The box had no space declared either: the same step declared it, and the one line says both (ruling 2026-10-16, Q1 A). */
744
- assert.ok(notice?.includes('figure.text.size-and-space-declared-to-follow'), `a line says so: ${notice}`)
745
- assert.equal(await read(() => !!document.querySelector('figure-editor').session.revisionNow().asset.occupancy), true, 'the space declared by the same step')
772
+ assert.ok(notice?.includes('figure.text.size-declared-to-follow'), `a line says so: ${notice}`)
746
773
  const grown = await read(() => {
747
774
  const editor = document.querySelector('figure-editor')
748
775
  editor.session.apply([{ kind: 'set-design', input: 'size.x', unit: 'mm', value: 800 }])
@@ -5,7 +5,7 @@
5
5
  * Only reading: every change the check makes goes through the mouse and the keyboard.
6
6
  */
7
7
  import './editing-page.js'
8
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
9
9
 
10
10
  const PALETTE = {
11
11
  'palette.dark': '#2b2b2b',
@@ -42,7 +42,7 @@ Object.assign(window.figures, {
42
42
  a = addFigureBox(a, { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 500, z: 0 } })
43
43
  a = addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 600, y: 50, z: 0 } })
44
44
  for (const part of ['housing', 'stray']) a = applyFigureAuthoring(a, { kind: 'attach', part, to: { plane: 'mounting-plane' } })
45
- return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
45
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
46
46
  },
47
47
  rounded: () => {
48
48
  let a = figure('Rounded')
@@ -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
 
@@ -239,25 +288,15 @@ describe('a part renamed in its name field: one step, still selected; a name ref
239
288
  })
240
289
  })
241
290
 
242
- describe('a part in the declared space removed: at once, the space fitted to the parts left, and said so (2026-09-27)', () => {
243
- test('one step, nothing asked, the space still declared, and one notice that it was fitted again', async () => {
291
+ describe('a part on the plane beside another removed: at once (2026-09-27)', () => {
292
+ test('one step, nothing asked', async () => {
244
293
  await mount('box', 'gate')
245
294
  await pick('stray')
246
295
  const before = await revision()
247
- const space = () => read(() => JSON.parse(document.querySelector('figure-editor').session.source()).occupancy ?? null)
248
- const was = await space()
249
- assert.ok(was, 'declared to begin with')
250
296
  await click(await read(() => parts.op('remove-part')), 'remove')
251
297
  assert.deepEqual(await ids(), ['housing'])
252
298
  assert.equal(await revision(), before + 1, 'one step')
253
299
  assert.equal(await read(() => editing.prompt()), null, 'nothing asked')
254
- const now = await space()
255
- assert.ok(now, 'the space is still declared')
256
- assert.notDeepEqual(now, was, 'fitted to the part left')
257
- const told = await read(() => editing.told())
258
- assert.deepEqual(told.map(t => t.level), ['info'], JSON.stringify(told))
259
- /* The page loads no translations: the words are the key. */
260
- assert.ok(told[0].message.includes('figure.text.removal-space-refitted'), told[0].message)
261
300
  })
262
301
  })
263
302
 
@@ -285,7 +285,7 @@ describe('the history panel on the page', () => {
285
285
  product.answer('figureVersions', () => [VERSION_1])
286
286
  product.answer('inspectFigure', () => ({
287
287
  blocked: true,
288
- findings: ['occupancy-undeclared', 'part-outside-occupancy', 'type-is-placeholder'].map(code => ({ code, message: code, why: null, how: null, at: null, blocking: true }))
288
+ findings: ['off-mounting-plane', 'parts-off-placement-face', 'type-is-placeholder'].map(code => ({ code, message: code, why: null, how: null, at: null, blocking: true }))
289
289
  }))
290
290
  }, VERSION_1)
291
291
  await open('h')
@@ -305,13 +305,12 @@ describe('the history panel on the page', () => {
305
305
  assert.deepEqual(link, { href: '/figure-package?refs=acme%2Fagv%401', download: true })
306
306
  })
307
307
 
308
- test('fitting to the parts is offered for the two findings it answers, and declares the room as one step', async () => {
309
- const offered = await until(p => { const found = product.deep(`${p} >>> li[finding]`); return found.length === 3 && found.filter(li => li.querySelector('button[fit-base]')).map(li => li.querySelector('[msg]').textContent).sort() }, 'the findings', PANEL)
310
- assert.deepEqual(offered, ['occupancy-undeclared', 'part-outside-occupancy'])
308
+ test('the findings stand in the history panel; an edit beside it is one step', async () => {
309
+ const shown = await until(p => { const found = product.deep(`${p} >>> li[finding]`); return found.length === 3 && found.map(li => li.querySelector('[msg]').textContent).sort() }, 'the findings', PANEL)
310
+ assert.deepEqual(shown, ['off-mounting-plane', 'parts-off-placement-face', 'type-is-placeholder'])
311
311
  const before = await read(() => product.view())
312
- await press(`${PANEL} >>> button[fit-base]`, 'fit')
312
+ await rename('H2')
313
313
  await until(r => product.view().revision === r + 1, 'one step', before.revision)
314
- await until(() => !!JSON.parse(product.view().source).occupancy, 'the room declared')
315
314
  await openHistory()
316
315
  })
317
316
 
@@ -557,7 +556,6 @@ describe('the page around the figure: the shell, the dock, the properties', () =
557
556
  })
558
557
  await until(r => product.view().revision === r + 1, 'one step', before.revision)
559
558
  assert.equal(JSON.parse((await read(() => product.view())).source).placement, 'ceiling')
560
- assert.equal(JSON.parse((await read(() => product.view())).source).occupancy, undefined, 'no room declared on the way')
561
559
  })
562
560
 
563
561
  test('unsaved: closing or reloading the tab is asked about', async () => {
@@ -806,8 +804,7 @@ describe('a mounting face named already', () => {
806
804
  const before = await read(() => product.view())
807
805
  await press('figure-properties >>> button[clear-mounting-face]', 'take the mounting face off')
808
806
  await until(r => product.view().revision === r + 1, 'one step', before.revision)
809
- assert.equal(JSON.parse((await read(() => product.view())).source).occupancy.contact, undefined, 'taken off')
810
- assert.ok(JSON.parse((await read(() => product.view())).source).occupancy.bounds, 'the room stays')
807
+ assert.equal(JSON.parse((await read(() => product.view())).source).mountingFace, undefined, 'taken off')
811
808
  await until(() => !product.deep('figure-modeller-page >>> figure-properties')[0].shadowRoot.querySelector('div[mounting-face]'), 'the line gone')
812
809
  })
813
810
 
@@ -20,7 +20,7 @@ import { store } from '@operato/shell'
20
20
  import { addressController } from '@operato/utils/address.js'
21
21
  import './popup-stand-in.js'
22
22
  import { html, render } from 'lit'
23
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
23
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
24
24
 
25
25
  const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
26
26
 
@@ -35,7 +35,6 @@ export function mountedSource(placement = 'floor') {
35
35
  let a = applyFigureAuthoring(createFigureAsset('Mounted'), { kind: 'set-placement', placement })
36
36
  a.palette = { ...a.palette, ...PALETTE }
37
37
  a = addFigureBox(a, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
38
- a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a) })
39
38
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-mounting-face', part: 'box', face: 'bottom' }))
40
39
  }
41
40
 
@@ -16,7 +16,6 @@ import {
16
16
  compileFigureAsset,
17
17
  createFigureAsset,
18
18
  editFigurePart,
19
- proposeFigureOccupancy,
20
19
  robotArmFigure,
21
20
  serializeFigureAsset
22
21
  } from '@hatiolab/figure-model'
@@ -67,18 +66,15 @@ const FIGURES = {
67
66
  let a = addFigureBox(named('Stack'), { id: 'base', size: { x: 1000, y: 200, z: 600 }, position: { x: 0, y: 100, z: 0 } })
68
67
  a = addFigureBox(a, { id: 'top', size: { x: 200, y: 200, z: 200 }, position: { x: 0, y: 300, z: 0 } })
69
68
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'top', to: { part: 'base', face: 'top' } })
70
- a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a) })
71
69
  return applyFigureAuthoring(a, { kind: 'set-mounting-face', part: 'base', face: 'bottom' })
72
70
  },
73
- /* A conveyor that follows its width, a roller every 200 mm, and the space it takes declared: the one figure stretched. */
71
+ /* A conveyor that follows its width, a roller every 200 mm: the one figure stretched. */
74
72
  stretch: () => {
75
73
  let a = addFigureBox(named('Stretch'), { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
76
74
  a = addFigurePrimitive(a, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
77
75
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', to: { part: 'frame', face: 'top' } })
78
76
  a = applyFigureAuthoring(a, { kind: 'follow-size', part: 'frame', axes: ['x'] })
79
- a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
80
- /* A space wider than the parts, as a spin swept or a padding gives: the box is the space, not the size (2026-10-16). */
81
- return applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), padding: { x: 100, y: 0, z: 0 } })
77
+ return applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
82
78
  },
83
79
  /* The robot arm with a clip that turns its base: controls and a clip in the preview. */
84
80
  'arm-clip': () => editFigurePart(robotArmFigure(), { kind: 'set-driver', state: 'j1-base', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })