@things-factory/figure-ui 10.1.205 → 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 (68) hide show
  1. package/client/modeller/editor-styles.ts +16 -0
  2. package/client/modeller/editor.ts +43 -41
  3. package/client/modeller/fasten-form.ts +50 -11
  4. package/client/modeller/figure-fasten-dialog.ts +3 -2
  5. package/client/modeller/figure-links.ts +2 -2
  6. package/client/modeller/figure-preview.ts +6 -6
  7. package/client/modeller/gizmo.ts +10 -0
  8. package/client/modeller/keys.ts +10 -1
  9. package/client/modeller/links.ts +32 -41
  10. package/client/modeller/palette.ts +1 -1
  11. package/client/modeller/product-session.ts +16 -4
  12. package/client/modeller/refusal-teller.ts +3 -25
  13. package/client/modeller/refusals.ts +19 -26
  14. package/dist-client/modeller/editor-styles.js +16 -0
  15. package/dist-client/modeller/editor-styles.js.map +1 -1
  16. package/dist-client/modeller/editor.d.ts +7 -3
  17. package/dist-client/modeller/editor.js +47 -42
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/fasten-form.d.ts +16 -7
  20. package/dist-client/modeller/fasten-form.js +45 -11
  21. package/dist-client/modeller/fasten-form.js.map +1 -1
  22. package/dist-client/modeller/figure-fasten-dialog.d.ts +1 -1
  23. package/dist-client/modeller/figure-fasten-dialog.js +3 -3
  24. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  25. package/dist-client/modeller/figure-links.d.ts +1 -1
  26. package/dist-client/modeller/figure-links.js +2 -2
  27. package/dist-client/modeller/figure-links.js.map +1 -1
  28. package/dist-client/modeller/figure-preview.js +4 -4
  29. package/dist-client/modeller/figure-preview.js.map +1 -1
  30. package/dist-client/modeller/gizmo.d.ts +6 -0
  31. package/dist-client/modeller/gizmo.js +11 -0
  32. package/dist-client/modeller/gizmo.js.map +1 -1
  33. package/dist-client/modeller/keys.d.ts +1 -1
  34. package/dist-client/modeller/keys.js +6 -0
  35. package/dist-client/modeller/keys.js.map +1 -1
  36. package/dist-client/modeller/links.d.ts +16 -22
  37. package/dist-client/modeller/links.js +26 -39
  38. package/dist-client/modeller/links.js.map +1 -1
  39. package/dist-client/modeller/palette.js +1 -1
  40. package/dist-client/modeller/palette.js.map +1 -1
  41. package/dist-client/modeller/product-session.d.ts +15 -13
  42. package/dist-client/modeller/product-session.js +15 -5
  43. package/dist-client/modeller/product-session.js.map +1 -1
  44. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  45. package/dist-client/modeller/refusal-teller.js +3 -22
  46. package/dist-client/modeller/refusal-teller.js.map +1 -1
  47. package/dist-client/modeller/refusals.d.ts +6 -14
  48. package/dist-client/modeller/refusals.js +16 -27
  49. package/dist-client/modeller/refusals.js.map +1 -1
  50. package/dist-client/route.d.ts +1 -1
  51. package/dist-client/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +7 -7
  53. package/test/browser/acceptance-page.js +1 -1
  54. package/test/browser/links.check.mjs +30 -13
  55. package/test/browser/modeller.check.mjs +45 -16
  56. package/test/browser/parts.check.mjs +49 -0
  57. package/test/browser/section.check.mjs +27 -0
  58. package/test/fasten-form.test.ts +42 -2
  59. package/test/gizmo.test.ts +12 -1
  60. package/test/keys.test.ts +11 -0
  61. package/test/links.test.ts +38 -33
  62. package/test/refusals.test.ts +17 -21
  63. package/translations/en.json +8 -13
  64. package/translations/es.json +8 -13
  65. package/translations/ja.json +8 -13
  66. package/translations/ko.json +8 -13
  67. package/translations/ms.json +8 -13
  68. package/translations/zh.json +8 -13
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.206",
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.9.6",
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.206",
46
+ "@things-factory/figure-service": "^10.1.206",
47
+ "@things-factory/figure-viewer": "^10.1.206",
48
+ "@things-factory/setting-base": "^10.1.206",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
51
+ "gitHead": "18588a058a4a6cb21c379b2da5517e077c6442ba"
52
52
  }
@@ -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. */
@@ -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')
@@ -399,9 +414,11 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
399
414
  assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: false, role: 'dialog' })
400
415
  })
401
416
 
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 () => {
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 () => {
403
419
  await aimAt('frame')
404
- 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')
405
422
  const told = (await heard('preview-command')).filter(Boolean).at(-1)
406
423
  assert.equal(told?.action.kind, 'attach')
407
424
  assert.equal((await source()).joints?.some(j => j.body1.part === 'leaf') ?? false, false, 'nothing written')
@@ -443,7 +460,7 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
443
460
 
444
461
  test('Esc closes the window and tells the editor to clear what it tried; the leaf stays selected', async () => {
445
462
  await aimAt('frame')
446
- 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')
447
464
  await read(() => look.hear('preview-command', 'dialog'))
448
465
  await page().keyboard.press('Escape')
449
466
  await until(() => !probe.fastenTargets(), [], 'the window closed')
@@ -459,9 +476,9 @@ describe('a new fastening for a door leaf: chosen in the window, seen before it
459
476
  await aimAt('frame')
460
477
  /*
461
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
462
- 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.
463
480
  */
464
- 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')
465
482
  await settled()
466
483
  await sleep(200)
467
484
  await click(await at('path[face="front"]'), 'the front face in the drawing')
@@ -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()
@@ -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
 
@@ -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, aimFastenAt, 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'), [
@@ -107,7 +147,7 @@ test('the target list: every other part, the one hanging from this part listed a
107
147
  async function curved() {
108
148
  const { addFigurePrimitive } = await import('@hatiolab/figure-model')
109
149
  let a = addFigurePrimitive(createFigureAsset('Curved'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 1600 }, position: { x: 0, y: 0, z: 0 } } as never)
110
- a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 100, radiusY: 100, radiusZ: 100 }, position: { x: 0, y: 0, z: 3000 } } as never)
150
+ a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radius: 100, radiusY: 100 }, position: { x: 0, y: 0, z: 3000 } } as never)
111
151
  a = box(a, 'tag', [40, 40, 40], [600, 600, 600])
112
152
  return box(a, 'cap', [40, 40, 40], [0, 600, 3000])
113
153
  }
@@ -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')
@@ -159,3 +159,14 @@ test('도구 막대의 옮기기 · 돌리기 · 크기 — 키와 같은 길,
159
159
  const ko = JSON.parse(read('../translations/ko.json'))
160
160
  assert.deepEqual(['translate', 'rotate', 'scale'].map(m => ko['figure.button.gizmo-' + m]), ['옮기기', '돌리기', '크기 바꾸기'])
161
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
+ })
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict'
10
10
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
11
11
  import type { FigureAsset } from '@hatiolab/figure-model'
12
12
 
13
- import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/links.ts'
13
+ import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/links.ts'
14
14
  import { revisionOf } from './revision-of.ts'
15
15
 
16
16
  const pair = (): FigureAsset => {
@@ -49,6 +49,10 @@ test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', ()
49
49
  assert.equal(canFasten(withDrum, 'drum'), true, '원기둥도 면으로 붙인다(figure-model 0.1.47)')
50
50
  assert.equal(canFollow(withDrum, 'drum'), true, '원기둥은 반지름(X)과 길이(Y)를 따를 수 있다')
51
51
  assert.deepEqual(followableAxes(withDrum, 'drum'), ['x', 'y'], '원기둥에는 제 Z 가 없다')
52
+ /* 구도 원기둥처럼 가로(X, Z 가 함께)와 높이(Y)를 따르고, 붙일 수 있다(사용자 2026-10-21). */
53
+ const withBall = addFigurePrimitive(asset, { id: 'ball', kind: 'sphere', dimensions: { radius: 50, radiusY: 50 }, position: { x: 0, y: 50, z: 0 } })
54
+ assert.deepEqual(followableAxes(withBall, 'ball'), ['x', 'y'], '구의 가로 반지름은 X 와 Z 가 한 값')
55
+ assert.equal(canFasten(withBall, 'ball'), true)
52
56
  })
53
57
 
54
58
  test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는다', () => {
@@ -206,38 +210,39 @@ test('made to follow in proportion, a measure is the share of the figure size it
206
210
  assert.deepEqual(followCommandOf(revisionOf(linked), 'flange', 'y', 'part:base:x', followOf(linked, 'flange', 'y')), { kind: 'link-dimension', part: 'flange', axis: 'y', source: { of: 'part', part: 'base', axis: 'x' }, replace: true })
207
211
  })
208
212
 
209
- test('놓일 수 있는 크기 — 남은 만큼 채우던 부품을 비례로 바꾸면 다시 재어 넓힌다', () => {
210
- assert.equal(instanceRangeOf(createFigureAsset('Probe')), undefined, '크기를 선언하지 않은 도형에는 범위가 없다')
211
- let asset = sizedPair()
213
+ /* ADR-0087 결정 6(사용자 2026-10-21): 작성자가 좁힌 끝만 저장되고, 나머지는 부품에서 figure-model 이 계산한다. */
214
+ const openSides = () => {
215
+ const asset = sizedPair()
212
216
  for (const axis of ['x', 'z'] as const) {
213
- asset.document.model.inputs.push({ id: `size.${axis}`, unit: 'mm', min: 1, max: 20000, role: 'design' } as never)
217
+ asset.document.model.inputs.push({ id: `size.${axis}`, unit: 'mm', role: 'design' } as never)
214
218
  asset.designInputs[`size.${axis}`] = 200
215
219
  }
220
+ return asset
221
+ }
222
+
223
+ test('도면에서의 크기 — 좁히지 않은 끝은 부품에서 계산하고, 좁힌 끝은 작성자의 값이다', () => {
224
+ assert.equal(instanceRangeOf(createFigureAsset('Probe')), undefined, '크기를 선언하지 않은 도형에는 범위가 없다')
225
+ const asset = openSides()
216
226
  const y = asset.document.model.inputs.find(i => i.id === 'size.y')!
217
227
  y.min = 90
218
228
  y.max = 1000
219
229
  const range = instanceRangeOf(asset)!
220
- assert.deepEqual(range.declared.y, { min: 90, max: 1000 })
221
- assert.deepEqual(range.measured.y, { min: 1, max: 1000 }, '기본 범위: 1 mm 에서 열 배')
222
- assert.equal(rangeDiffers(range.declared, range.measured), true)
223
- asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: range.measured, replace: true })
224
- assert.equal(rangeDiffers(instanceRangeOf(asset)!.declared, instanceRangeOf(asset)!.measured), false, '다시 재면 같다')
230
+ assert.deepEqual(range.y, { min: 90, max: 1000, narrowed: { min: true, max: true } })
231
+ assert.deepEqual(range.x, { min: 0, max: Infinity, narrowed: { min: false, max: false } }, '막는 부품이 없으면 끝이 없다')
225
232
  })
226
233
 
227
- test('놓일 수 있는 크기 — 저작자가 끝을 적어 좁힌다(갠트리, 09-25)', () => {
228
- let asset = sizedPair()
229
- for (const axis of ['x', 'z'] as const) {
230
- asset.document.model.inputs.push({ id: `size.${axis}`, unit: 'mm', min: 1, max: 20000, role: 'design' } as never)
231
- asset.designInputs[`size.${axis}`] = 200
232
- }
233
- const declared = instanceRangeOf(asset)!.declared
234
- assert.equal(rangeWith(declared, 'x', 'min', Number.NaN), undefined, '숫자가 아니면 명령이 없다')
235
- const next = rangeWith(declared, 'x', 'min', 150)!
236
- assert.deepEqual(next.x, { min: 150, max: 20000 })
237
- assert.deepEqual(declared.x, { min: 1, max: 20000 }, '선언된 범위는 그대로')
238
- asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: next, replace: true })
239
- assert.deepEqual(instanceRangeOf(asset)!.declared.x, { min: 150, max: 20000 })
240
- assert.throws(() => applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: rangeWith(next, 'x', 'min', 300)!, replace: true }), (e: { code?: string }) => e.code === 'INSTANCE_SIZE_RANGE', '저작한 크기(200)를 벗어나면 거절')
234
+ test('도면에서의 크기 — 숫자를 치면 그 끝을 좁히고, 칸을 비우면 다시 연다(갠트리, 09-25)', () => {
235
+ let asset = openSides()
236
+ const range = instanceRangeOf(asset)!
237
+ assert.deepEqual(rangeWith(range, 'x', 'min', 150), { x: { min: 150 }, y: { min: 1, max: 20000 } }, '다른 축의 좁힌 끝은 그대로')
238
+ assert.deepEqual(rangeWith(range, 'y', 'min', Number.NaN), { y: { max: 20000 } }, '비운 칸은 열린다')
239
+ asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: rangeWith(range, 'x', 'min', 150), replace: true })
240
+ assert.deepEqual(instanceRangeOf(asset)!.x, { min: 150, max: Infinity, narrowed: { min: true, max: false } })
241
+ assert.throws(
242
+ () => applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: rangeWith(instanceRangeOf(asset)!, 'x', 'min', 300), replace: true }),
243
+ (e: { code?: string }) => e.code === 'INSTANCE_SIZE_RANGE',
244
+ '저작한 크기(200)를 벗어나면 거절'
245
+ )
241
246
  })
242
247
 
243
248
  test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한 번에 따라가게 하며, 무엇이 남는지 이름을 댄다(아키텍트 판정 09-25)', () => {
@@ -376,7 +381,7 @@ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구
376
381
  dimensions: { width: 1100, height: 2200, depth: 150, jamb: 100, head: 100 },
377
382
  position: { x: 0, y: 0, z: 0 }
378
383
  })
379
- asset = addFigurePrimitive(asset, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 }, position: { x: 0, y: 0, z: 0 } })
384
+ asset = addFigurePrimitive(asset, { id: 'ball', kind: 'sphere', dimensions: { radius: 50, radiusY: 50 }, position: { x: 0, y: 0, z: 0 } })
380
385
  asset = addFigurePrimitive(asset, { id: 'cone', kind: 'frustum', dimensions: { radiusTop: 20, radiusBottom: 60, height: 100 }, position: { x: 0, y: 0, z: 0 } })
381
386
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
382
387
  asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 0, z: 0 } })
@@ -427,18 +432,18 @@ test('돌려 놓인 부품에도 모서리 맞춤을 내놓는다 — 지금 자
427
432
  })
428
433
 
429
434
  test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선언한다 — 그 뒤에야 부품이 도형 크기를 따를 수 있다 (사용자 09-25)', async () => {
430
- const { instanceRangeToDeclare, instanceRangeOf, hasInstanceSize } = await import('../client/modeller/links.ts')
435
+ const { instanceSizeToDeclare, instanceRangeOf, hasInstanceSize } = await import('../client/modeller/links.ts')
431
436
  const { compileFigureAsset } = await import('@hatiolab/figure-model')
432
437
  let asset = createFigureAsset('Conveyor')
433
- assert.equal(instanceRangeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
438
+ assert.equal(instanceSizeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
434
439
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
435
440
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
436
441
  assert.equal(hasInstanceSize(asset, 'x'), false, '새 도형에는 도형 크기가 없다')
437
- const toDeclare = instanceRangeToDeclare(asset)!
442
+ const toDeclare = instanceSizeToDeclare(asset)!
438
443
  assert.deepEqual(toDeclare.at, { x: 2000, y: 100, z: 600 }, '지금 부품의 크기가 선언할 크기')
439
- asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })
440
- assert.ok(instanceRangeOf(asset), '선언 뒤에는 놓일 수 있는 크기가 보인다')
441
- assert.equal(instanceRangeToDeclare(asset), undefined, '이미 있으면 다시 권하지 않는다')
444
+ asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', at: toDeclare.at })
445
+ assert.ok(instanceRangeOf(asset), '선언 뒤에는 도면에서의 크기가 보인다')
446
+ assert.equal(instanceSizeToDeclare(asset), undefined, '이미 있으면 다시 권하지 않는다')
442
447
  assert.equal(hasInstanceSize(asset, 'x'), true, '부품이 도형 크기를 따르는 선택이 열린다')
443
448
  asset = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'frame', axis: 'x', source: { of: 'instance', axis: 'x' } } as never)
444
449
  const at = (size: number) => (compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, 'size.x': size } }).evaluate().geometry.find(g => g.id === 'frame')!.shape.dimensions as Record<string, number>)['size.x']
@@ -505,7 +510,7 @@ test('면 위 거리를 도형 크기 비율로 — 도형이 커지면 그 자
505
510
  })
506
511
 
507
512
  test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이 도는 자리까지 재지 않는다 (2026-09-25)', async () => {
508
- const { instanceRangeToDeclare } = await import('../client/modeller/links.ts')
513
+ const { instanceSizeToDeclare } = await import('../client/modeller/links.ts')
509
514
  let asset = createFigureAsset('Door')
510
515
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
511
516
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
@@ -513,7 +518,7 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
513
518
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
514
519
  const j = asset.joints!.find(o => o.id === 'leaf')!
515
520
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
516
- assert.deepEqual(instanceRangeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
521
+ assert.deepEqual(instanceSizeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
517
522
  })
518
523
 
519
524
  test('바닥에 선 부품도 도형 크기를 선언한 도형이면 도형의 옆면에 맞출 수 있다 (2026-09-25)', async () => {