@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
@@ -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)', () => {
@@ -367,8 +372,8 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
367
372
  assert.deepEqual(motionOf(asset, 'right')?.follows, { sum: [{ joint: 'left', multiplier: -1 }], offset: 0 }, '화면은 무엇을 따르는지 읽는다')
368
373
  })
369
374
 
370
- test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구부 셋, 공은 없음, 원뿔대는 위아래뿐 (2026-09-25)', async () => {
371
- const { facesOf, faceAxisOf, canFasten, alignAxesOf } = await import('../client/modeller/links.ts')
375
+ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구부 셋, 공은 없고 곡면, 원뿔대는 위아래뿐, 원기둥 옆은 면이 아님 (2026-09-25, ADR-0140 3.9)', async () => {
376
+ const { facesOf, faceAxisOf, canFasten, alignAxesOf, surfaceOf } = await import('../client/modeller/links.ts')
372
377
  let asset = createFigureAsset('Doorway')
373
378
  asset = addFigurePrimitive(asset, {
374
379
  id: 'frame',
@@ -376,13 +381,18 @@ 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 } })
387
+ asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 0, z: 0 } })
382
388
  assert.deepEqual(facesOf(asset, 'frame'), ['left', 'right', 'bottom', 'top', 'back', 'front', 'opening-left', 'opening-right', 'opening-top'])
383
389
  assert.equal(faceAxisOf(asset, 'frame', 'opening-top'), 'y')
384
390
  assert.deepEqual(facesOf(asset, 'ball'), [])
385
- assert.equal(canFasten(asset, 'ball'), false, '공은 붙일 면이 없다')
391
+ assert.equal(canFasten(asset, 'ball'), true, '공은 면이 없어도 곡면 위 점에 붙인다')
392
+ assert.deepEqual(surfaceOf(asset, 'ball'), { form: 'toward', axis: 'y' })
393
+ assert.deepEqual(facesOf(asset, 'drum'), ['bottom', 'top'], '원기둥 옆은 곡면이라 면이 아니다')
394
+ assert.deepEqual(surfaceOf(asset, 'drum'), { form: 'around', axis: 'y' })
395
+ assert.equal(surfaceOf(asset, 'cone'), undefined, '원뿔대 옆은 아직 받지 않는다')
386
396
  assert.equal(canFasten(asset, 'frame'), true)
387
397
  assert.deepEqual(facesOf(asset, 'cone'), ['bottom', 'top'])
388
398
  assert.deepEqual(alignAxesOf(asset, { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
@@ -422,18 +432,18 @@ test('돌려 놓인 부품에도 모서리 맞춤을 내놓는다 — 지금 자
422
432
  })
423
433
 
424
434
  test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선언한다 — 그 뒤에야 부품이 도형 크기를 따를 수 있다 (사용자 09-25)', async () => {
425
- const { instanceRangeToDeclare, instanceRangeOf, hasInstanceSize } = await import('../client/modeller/links.ts')
435
+ const { instanceSizeToDeclare, instanceRangeOf, hasInstanceSize } = await import('../client/modeller/links.ts')
426
436
  const { compileFigureAsset } = await import('@hatiolab/figure-model')
427
437
  let asset = createFigureAsset('Conveyor')
428
- assert.equal(instanceRangeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
438
+ assert.equal(instanceSizeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
429
439
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
430
440
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
431
441
  assert.equal(hasInstanceSize(asset, 'x'), false, '새 도형에는 도형 크기가 없다')
432
- const toDeclare = instanceRangeToDeclare(asset)!
442
+ const toDeclare = instanceSizeToDeclare(asset)!
433
443
  assert.deepEqual(toDeclare.at, { x: 2000, y: 100, z: 600 }, '지금 부품의 크기가 선언할 크기')
434
- asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })
435
- assert.ok(instanceRangeOf(asset), '선언 뒤에는 놓일 수 있는 크기가 보인다')
436
- 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, '이미 있으면 다시 권하지 않는다')
437
447
  assert.equal(hasInstanceSize(asset, 'x'), true, '부품이 도형 크기를 따르는 선택이 열린다')
438
448
  asset = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'frame', axis: 'x', source: { of: 'instance', axis: 'x' } } as never)
439
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']
@@ -500,7 +510,7 @@ test('면 위 거리를 도형 크기 비율로 — 도형이 커지면 그 자
500
510
  })
501
511
 
502
512
  test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이 도는 자리까지 재지 않는다 (2026-09-25)', async () => {
503
- const { instanceRangeToDeclare } = await import('../client/modeller/links.ts')
513
+ const { instanceSizeToDeclare } = await import('../client/modeller/links.ts')
504
514
  let asset = createFigureAsset('Door')
505
515
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
506
516
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
@@ -508,7 +518,7 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
508
518
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
509
519
  const j = asset.joints!.find(o => o.id === 'leaf')!
510
520
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
511
- assert.deepEqual(instanceRangeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
521
+ assert.deepEqual(instanceSizeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
512
522
  })
513
523
 
514
524
  test('바닥에 선 부품도 도형 크기를 선언한 도형이면 도형의 옆면에 맞출 수 있다 (2026-09-25)', async () => {
@@ -17,7 +17,6 @@ import {
17
17
  editRefusalOf,
18
18
  inDeclaredRoom,
19
19
  inputRangeRefusalOf,
20
- narrowingRetryOf,
21
20
  partNameRefusalOf,
22
21
  partRefusalOf,
23
22
  placedRefusalOf,
@@ -26,6 +25,7 @@ import {
26
25
  refusalRecordOf,
27
26
  removalNoticeOf,
28
27
  repeatPitchRefusalOf,
28
+ sizeRangeFitsOf,
29
29
  sizeRangeRefusalOf
30
30
  } from '../client/modeller/refusals.ts'
31
31
  import { wordsOf } from './words.ts'
@@ -35,6 +35,7 @@ const say = (key: string, params: Record<string, string>) => {
35
35
  assert.ok(key in ko, `no words for ${key}`)
36
36
  return ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
37
37
  }
38
+
38
39
  /** The key and its values, to see which words were chosen. */
39
40
  const keyed = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
40
41
 
@@ -66,32 +67,27 @@ function conveyor(): FigureAsset {
66
67
  return act(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
67
68
  }
68
69
 
69
- test('크기 범위의 거절 다섯 — 축 · 숫자 · 부품을 커널이 넘긴 값으로 말한다', () => {
70
- const small = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(-50))), say)!
71
- assert.ok(small.includes('X 축을') && small.includes('50 mm') && small.includes('panel 부품') && small.includes('다시 재기'), small)
72
- const large = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(1000, { times: -1 }))), say)!
73
- assert.ok(large.includes('X 축을') && large.includes('1,000 mm') && large.includes('그릴 수 없는데'), large)
70
+ /*
71
+ * 크기 범위로 남은 거절은 둘 — 작성자가 친 끝이 지금 크기를 빼거나 0 에서 시작할 때(ADR-0087 결정 6). 부품을 바꾸는 명령은
72
+ * 범위 때문에 거절되지 않고, 작성자가 좁힌 끝이 부품이 허락하는 데까지 옮겨진 것을 한 줄로 말한다(사용자 2026-10-21).
73
+ */
74
+ test('크기 범위의 거절 둘 — 축 이름 · 숫자를 커널이 넘긴 값으로 말한다', () => {
74
75
  const outside = sizeRangeRefusalOf(refusalOf(() => act(box(), declaring({ min: 500, max: 4000 }))), say)!
75
- assert.ok(outside.includes('X') && outside.includes('400 mm') && outside.includes('500') && outside.includes('4,000'), outside)
76
+ assert.equal(outside, '도면에서의 너비 (X) 범위 500 – 4,000 mm 에 지금 크기 400 mm 가 들어가지 않습니다.')
77
+ const open = sizeRangeRefusalOf(refusalOf(() => act(box(), { kind: 'declare-instance-size', range: { x: { min: 500 } } })), say)!
78
+ assert.equal(open, '도면에서의 너비 (X) 범위 500 – ∞ mm 에 지금 크기 400 mm 가 들어가지 않습니다.', '한쪽 끝만 친 범위')
76
79
  const zero = sizeRangeRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say)!
77
- assert.ok(zero.includes('X') && zero.includes('0 보다'), zero)
78
- const gone = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(-399.9999995, { narrowRange: true }))), say)!
79
- assert.ok(gone.includes('X') && gone.includes('400 mm') && gone.includes('이미 0'), gone)
80
- for (const said of [small, large, outside, zero, gone]) assert.doesNotMatch(said, /below|above|declare|authored|INSTANCE_SIZE_RANGE/)
80
+ assert.equal(zero, '도면에서의 너비 (X) 최소는 0 보다 커야 합니다.')
81
+ for (const said of [outside, open, zero]) assert.doesNotMatch(said, /below|above|declare|authored|INSTANCE_SIZE_RANGE|다시 재기/)
81
82
  assert.equal(sizeRangeRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 })), say), null, '다른 거절은 건드리지 않는다')
82
83
  })
83
84
 
84
- test('범위가 받을 수 없는 연결은 같은 연결을 범위를 좁혀 다시 권한다 — 다시 재기로는 풀리지 않는다 (사이징, 2026-09-25)', () => {
85
+ test('부품을 줄이는 연결은 거절되지 않고, 좁혀 둔 끝이 옮겨진 것을 한 줄로 말한다 (사용자 2026-10-21)', () => {
85
86
  const asset = panel()
86
- const link = followsWidth(-50)
87
- const error = refusalOf(() => act(asset, link))
88
- const offer = narrowingRetryOf(link, error, keyed)!
89
- assert.ok(offer, '권한다')
90
- assert.match(offer.label, /figure.button.narrow-range-from .*"axis":"X"/)
91
- assert.match(offer.message, /link-needs-smaller-range .*"part":"panel"/)
92
- assert.deepEqual(offer.retry, { ...link, narrowRange: true })
93
- assert.doesNotThrow(() => act(asset, offer.retry), '권한 것은 받아들여진다')
94
- assert.equal(narrowingRetryOf({ kind: 'attach' }, error, keyed), null, '연결이 아닌 명령에는 권하지 않는다')
87
+ const linked = act(asset, followsWidth(-50))
88
+ const fits = fm.figureSizeRangeFitsOf(asset, linked)
89
+ assert.equal(sizeRangeFitsOf(fits, say), '도면에서의 너비 (X) 최소를 1 mm 에서 50 mm 로 바꿨습니다.')
90
+ assert.equal(sizeRangeFitsOf([], say), '', '옮긴 것이 없으면 말하지 않는다')
95
91
  })
96
92
 
97
93
  test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그 이름을 한 줄로 말한다; 남는 거절은 공간 선언과 줄 (아키텍트 판정 2026-09-27)', () => {
@@ -117,22 +117,20 @@ test('a field only the room reads is taken out of the part\'s position row and s
117
117
  })
118
118
 
119
119
  /*
120
- * Designer 2026-09-27, 1: what the drag shows is what is written. The part component carries figure-model's anchor, and
121
- * the scene's gizmo keeps that face while dragging (things-scene `scaleAnchor`); the commit keeps the same face.
120
+ * ADR-0140 4.4: what the drag shows is what is written. The part component asks the scene to leave its place to the
121
+ * modeller (things-scene `scalePlacement: 'owner'`), which draws each frame from figure-model's one size function --
122
+ * whatever the placement; no face is named to the scene.
122
123
  */
123
- test('the part component carries the face a size drag keeps, for each placement', async () => {
124
+ test('the part component leaves its place in a size drag to the modeller, for each placement', async () => {
124
125
  const { componentOf } = await import('../client/modeller/stage-model.js')
125
- const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
126
- const expected = { floor: { x: 0, y: 1, z: 0 }, ceiling: { x: 0, y: -1, z: 0 }, wall: { x: 0, y: 0, z: 1 }, center: { x: 0, y: 0, z: 0 } } as const
127
126
  for (const mode of ['floor', 'ceiling', 'wall', 'center'] as const) {
128
127
  let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: START[mode] })
129
128
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: mode })
130
- const anchor = figureScaleAnchorOf(a, 'box')!
131
- assert.deepEqual(anchor, expected[mode], mode)
132
129
  const body = compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'box') as never
133
- const state = componentOf(body, {}, { width: 1000, height: 1000 }, anchor)!
134
- assert.deepEqual(state.scaleAnchor, expected[mode])
135
- assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scaleAnchor, undefined, 'none given, none set')
130
+ const state = componentOf(body, {}, { width: 1000, height: 1000 }, true)!
131
+ assert.equal(state.scalePlacement, 'owner', mode)
132
+ assert.equal(state.scaleAnchor, undefined, `${mode}: no face named to the scene`)
133
+ assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scalePlacement, undefined, 'a shown-only body is not')
136
134
  }
137
135
  })
138
136
 
@@ -155,12 +153,15 @@ test('size response and attachment are separate screen paths', () => {
155
153
  assert.equal(ko['figure.text.held-on-plane'].replace('{place}', '바닥').replace('{gap}', '0'), '바닥에 붙어 있음 (간격 0 mm)')
156
154
  })
157
155
 
158
- test('a part fastened tilted off a quarter turn has no side figure-model can keep for a drag, so the size gizmo is withheld', async () => {
159
- const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
156
+ test('a part fastened at any angle is sized by the gizmo too: its fastening keeps it, and nothing else is written', async () => {
157
+ const { figureSizeEditOf } = await import('@hatiolab/figure-model')
158
+ const { gizmoReachOf } = await import('../client/modeller/gizmo.js')
160
159
  let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
161
160
  a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
162
161
  a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', type: 'fixed', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
163
- assert.equal(figureScaleAnchorOf(a, 'blade'), undefined)
162
+ assert.ok(gizmoReachOf(a, 'blade').scale, 'offered')
163
+ const size = [{ kind: 'set-design', input: 'blade.size.x', unit: 'mm', value: 400 }] as never
164
+ assert.deepEqual(figureSizeEditOf(a, 'blade', size, { keep: 'opposite', axis: 'x', side: 1 }), { changes: size, kept: 'relation' })
164
165
  })
165
166
 
166
167
  /*
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { fileURLToPath } from 'node:url'
14
14
 
15
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, figureFaceOverhangOf, figurePartFaceOf, figurePlaneAlignChanges, figureRoomOnlyInputsOf, figureRowOf, figureScaleAnchorOf, measureFigureWork, serializeFigureAsset } from '@hatiolab/figure-model'
15
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, figureFaceOverhangOf, figurePartFaceOf, figurePlaneAlignChanges, figureRoomOnlyInputsOf, figureRowOf, measureFigureWork, serializeFigureAsset } from '@hatiolab/figure-model'
16
16
  import type { FigureAsset } from '@hatiolab/figure-model'
17
17
 
18
18
  import { createFigureProductSession } from '../client/modeller/product-session.ts'
@@ -24,6 +24,7 @@ import { faceViewOf } from '../client/modeller/fasten-geometry.ts'
24
24
  import { motionEndsOf } from '../client/modeller/motion-ends.ts'
25
25
  import { figureFigureFacts } from '../client/modeller/figure-facts.ts'
26
26
  import { gizmoReachOf } from '../client/modeller/gizmo.ts'
27
+ import { keptBySizeOf } from '../client/modeller/size-drag.ts'
27
28
  import { moving } from './joints.ts'
28
29
  import { revisionOf } from './revision-of.ts'
29
30
 
@@ -82,7 +83,7 @@ test('a version is readable while it is saved, and a save hands out the same tex
82
83
  function selectionQuestions(shown: FigureRevision, part: string) {
83
84
  const asset = shown.asset
84
85
  const reach = gizmoReachOf(asset as never, part)
85
- figureScaleAnchorOf(asset, part)
86
+ keptBySizeOf(asset, part)
86
87
  boxOf(shown, part)
87
88
  gridStepOf(shown)
88
89
  jointFanOf(shown, part)
@@ -173,7 +173,7 @@ test('사각형·원도 같은 창에서 — 폭·지름·길이를 한 명령
173
173
  const next = applyFigureAuthoring(box, { kind: 'set-section', part: 'b', across: { x: 500 }, length: 260 })
174
174
  const b = (compileFigureAsset(next).evaluate().geometry.find(g => (g as { id: string }).id === 'b') as unknown as { shape: FigureEvaluatedShape; pose: { t: number[] } })
175
175
  assert.deepEqual(b.shape.extrusion!.section, { kind: 'rectangle', size: { x: 500, z: 300 } })
176
- assert.equal(b.pose.t[1]! + b.shape.extrusion!.start, 0, 'a floor figure keeps its bottom')
176
+ assert.deepEqual([b.pose.t[1]! + b.shape.extrusion!.start, b.pose.t[1]! + b.shape.extrusion!.end], [-30, 230], 'a free part keeps its middle (ADR-0140 4.1)')
177
177
  /* A corner that no longer fits, and a length of 0, are said before anything is sent. */
178
178
  const rounded = applyFigureAuthoring(box, { kind: 'set-section-corner', part: 'b', corner: 100 })
179
179
  const r = (compileFigureAsset(rounded).evaluate().geometry.find(g => (g as { id: string }).id === 'b') as unknown as { shape: FigureEvaluatedShape }).shape
@@ -217,15 +217,14 @@ test('창 안에서 폭과 속 공간을 함께 — 넓힌 폭에서만 들어
217
217
  assert.deepEqual(sectionActionOf('t', 'rectangle', w, { ...w, cavity: null }), { kind: 'set-section', part: 't', cavity: null })
218
218
  })
219
219
 
220
- test('치수 칸 밑의 말: 다른 값을 따르면 그렇다고, 길이는 관계가 남기는 면을 — 말할 것이 없으면 아무것도', () => {
221
- assert.deepEqual(measureNoteOf('linked', 1, 'y'), { key: 'figure.text.section-editor-linked' }, 'following another value is said first')
222
- assert.deepEqual(measureNoteOf('editable', 1, 'y'), { key: 'figure.text.section-editor-keeps', face: 'bottom' }, 'a floor part keeps its start: the bottom')
223
- assert.deepEqual(measureNoteOf('editable', -1, 'y'), { key: 'figure.text.section-editor-keeps', face: 'top' })
224
- assert.deepEqual(measureNoteOf('editable', -1, 'x'), { key: 'figure.text.section-editor-keeps', face: 'right' })
225
- assert.deepEqual(measureNoteOf('editable', 0, 'y'), { key: 'figure.text.section-editor-keeps-centre' })
226
- assert.equal(measureNoteOf('editable', undefined, 'y'), null, 'the relations cannot say: nothing')
227
- assert.equal(measureNoteOf(undefined, undefined, 'x'), null, 'a width across says only that it follows')
228
- assert.equal(say('figure.text.section-editor-keeps', { face: ko['figure.label.face-bottom']! }), '아랫면 유지')
220
+ test('치수 칸 밑의 말: 다른 값을 따르면 그렇다고, 길이는 가운데나 붙인 위치를 지킨다고 — 말할 것이 없으면 아무것도', () => {
221
+ assert.deepEqual(measureNoteOf('linked', 'centre'), { key: 'figure.text.section-editor-linked' }, 'following another value is said first')
222
+ assert.deepEqual(measureNoteOf('editable', 'centre'), { key: 'figure.text.size-keeps-centre' }, 'a free part keeps its middle (ADR-0140 4.1)')
223
+ assert.deepEqual(measureNoteOf('editable', 'relation'), { key: 'figure.text.size-keeps-fastening' }, 'a fastened part keeps where it is fastened')
224
+ assert.deepEqual(measureNoteOf('editable', 'none'), { key: 'figure.text.size-keeps-none' }, 'a position worked out: the size alone changes')
225
+ assert.equal(measureNoteOf('editable', undefined), null, 'nothing known: nothing')
226
+ assert.equal(measureNoteOf(undefined, undefined), null, 'a width across says only that it follows')
227
+ assert.deepEqual([say('figure.text.size-keeps-centre', {}), say('figure.text.size-keeps-fastening', {})], ['가운데 유지', '붙인 위치 유지'])
229
228
  })
230
229
 
231
230
  test('ADR-0100 A1: 상자·판·벽은 같은 돌출에 처음 치수만 다르고, 더해도 숨은 부착이나 연결이 생기지 않는다', async () => {
@@ -0,0 +1,114 @@
1
+ /*
2
+ * A size drag drawn from figure-model frame by frame (ADR-0140 4.4, `size-drag.ts`): each frame is what its release
3
+ * writes -- the same changes, and every body where the committed figure puts it, to the bit. A free part keeps the
4
+ * side opposite the handle; a fastened one moves what hangs from it, by its fastening.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, figureSizeEditOf } from '@hatiolab/figure-model'
10
+ import type { FigureAsset, FigureInputChange, FigureSizeKeep } from '@hatiolab/figure-model'
11
+
12
+ import { partsAtFrame, startSizeDrag } from '../client/modeller/size-drag.ts'
13
+ import { drawableFigureParts, type FigureRenderPart } from '../client/modeller/renderer.ts'
14
+
15
+ const set = (input: string, value: number): FigureInputChange => ({ kind: 'set-design', input, unit: 'mm', value })
16
+ const committed = (asset: FigureAsset, changes: FigureInputChange[]) =>
17
+ drawableFigureParts(compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, ...Object.fromEntries(changes.map(c => [c.input, c.value])) } }).evaluate().geometry)
18
+ const drawn = (asset: FigureAsset) => drawableFigureParts(compileFigureAsset(asset).evaluate().geometry)
19
+ const posesOf = (parts: FigureRenderPart[]) => Object.fromEntries(parts.map(p => [p.id, { r: p.pose.r, t: p.pose.t, dimensions: p.shape.dimensions }]))
20
+
21
+ /* A turned box on the floor, and a lid fastened on its top. */
22
+ function figure() {
23
+ let a = addFigureBox(createFigureAsset('Drag'), { id: 'box', size: { x: 200, y: 100, z: 150 }, position: { x: 40, y: 300, z: -20 }, rotation: { x: 10, y: 30, z: 0 } })
24
+ a = addFigureBox(a, { id: 'base', size: { x: 400, y: 100, z: 300 }, position: { x: 0, y: 50, z: 0 } })
25
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'base', to: { plane: 'mounting-plane' } })
26
+ a = addFigureBox(a, { id: 'lid', size: { x: 100, y: 20, z: 100 }, position: { x: 0, y: 0, z: 0 } })
27
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'lid', to: { part: 'base', face: 'top' } })
28
+ }
29
+
30
+ test('each frame of a free part is what its release writes: the same changes, every body where the commit puts it', () => {
31
+ const asset = figure()
32
+ const keep: FigureSizeKeep = { keep: 'opposite', axis: 'x', side: 1 }
33
+ const drag = startSizeDrag(asset, 'box', {}, keep)
34
+ for (const width of [230, 300, 180]) {
35
+ const sizes = [set('box.size.x', width)]
36
+ const frame = drag.frame(sizes)
37
+ assert.deepEqual(frame.changes, figureSizeEditOf(asset, 'box', sizes, keep).changes, `${width}: the changes`)
38
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)), `${width}: the bodies`)
39
+ }
40
+ })
41
+
42
+ test('a fastened part dragged: what hangs from it moves by its fastening, frame by frame, as the commit puts it', () => {
43
+ const asset = figure()
44
+ const drag = startSizeDrag(asset, 'base', {}, { keep: 'opposite', axis: 'y', side: 1 })
45
+ for (const height of [140, 220]) {
46
+ const frame = drag.frame([set('base.size.y', height)])
47
+ assert.equal(frame.changes.length, 1, 'its size alone: its fastening places it')
48
+ assert.ok(frame.moved.has('lid'), 'the lid on its top moves with it')
49
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)), `${height}`)
50
+ }
51
+ })
52
+
53
+ test('a body that moved and then stood still is drawn where it stopped, not where the drag began', () => {
54
+ const asset = figure()
55
+ const drag = startSizeDrag(asset, 'base', {}, { keep: 'centre' })
56
+ drag.frame([set('base.size.y', 200)])
57
+ /* The same height again: nothing changes in this frame, and the lid stays up where the last frame put it. */
58
+ const frame = drag.frame([set('base.size.y', 200)])
59
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)))
60
+ })
61
+
62
+ test('the handle held is what is kept from: one axis keeps the side opposite it; the uniform handle, or none, the middle', async () => {
63
+ const { sizeKeepOf } = await import('../client/modeller/gizmo.ts')
64
+ assert.deepEqual(sizeKeepOf({ x: 0, y: 1, z: 0 }), { keep: 'opposite', axis: 'y', side: 1 })
65
+ assert.deepEqual(sizeKeepOf({ x: -1, y: 0, z: 0 }), { keep: 'opposite', axis: 'x', side: -1 })
66
+ assert.deepEqual(sizeKeepOf({ x: 1, y: 1, z: 1 }), { keep: 'centre' }, 'the uniform handle')
67
+ assert.deepEqual(sizeKeepOf(undefined), { keep: 'centre' }, 'no handle known')
68
+ })
69
+
70
+ test('what a size typed keeps, as the panel says it: the middle of a free part, the fastening of a fastened one', async () => {
71
+ const { keptBySizeOf } = await import('../client/modeller/size-drag.ts')
72
+ const asset = figure()
73
+ assert.deepEqual(['box', 'base', 'lid'].map(part => keptBySizeOf(asset, part)), ['centre', 'relation', 'relation'])
74
+ /* Its height placed by a rule: no point is kept, only the size changes -- said as such (chief architect 10-20). */
75
+ const ruled = structuredClone(asset) as FigureAsset
76
+ ;(ruled.document.model as unknown as { nodes: Array<{ id: string; args: string[] }> }).nodes.find(n => n.id === 'box.pose')!.args[1] = 'box.position.x'
77
+ assert.equal(keptBySizeOf(ruled, 'box'), 'none')
78
+ })
79
+
80
+ test('how far a free part goes past its mounting plane: its real extreme against the side its placement uses', async () => {
81
+ const { throughPlaneOf } = await import('../client/modeller/size-drag.ts')
82
+ const box = (placement: 'floor' | 'ceiling' | 'wall' | 'center', position: { x: number; y: number; z: number }, rotation = { x: 0, y: 0, z: 0 }) =>
83
+ applyFigureAuthoring(addFigureBox(createFigureAsset('P'), { id: 'b', size: { x: 100, y: 100, z: 100 }, position, rotation }), { kind: 'set-placement', placement })
84
+ assert.equal(throughPlaneOf(box('floor', { x: 0, y: 20, z: 0 }), drawn(box('floor', { x: 0, y: 20, z: 0 })), 'b'), 30, 'its bottom 30 under the floor')
85
+ assert.equal(throughPlaneOf(box('floor', { x: 0, y: 50, z: 0 }), drawn(box('floor', { x: 0, y: 50, z: 0 })), 'b'), 0, 'standing on it')
86
+ assert.equal(throughPlaneOf(box('ceiling', { x: 0, y: -40, z: 0 }), drawn(box('ceiling', { x: 0, y: -40, z: 0 })), 'b'), 10, 'its top 10 above the ceiling')
87
+ assert.equal(throughPlaneOf(box('wall', { x: 0, y: 0, z: 45 }), drawn(box('wall', { x: 0, y: 0, z: 45 })), 'b'), 5, 'its back 5 into the wall')
88
+ assert.equal(throughPlaneOf(box('center', { x: 0, y: -500, z: 0 }), drawn(box('center', { x: 0, y: -500, z: 0 })), 'b'), 0, 'a figure placed by its centre has no plane')
89
+ /* Turned 45° about Z, its lowest corner is √2·50 under its middle. */
90
+ const turned = box('floor', { x: 0, y: 60, z: 0 }, { x: 0, y: 0, z: 45 })
91
+ assert.equal(throughPlaneOf(turned, drawn(turned), 'b'), Math.round((50 * Math.SQRT2 - 60) * 10) / 10, 'its real corner, not its upright box')
92
+ const fastened = applyFigureAuthoring(box('floor', { x: 0, y: 20, z: 0 }), { kind: 'attach', part: 'b', to: { plane: 'mounting-plane' } })
93
+ assert.equal(throughPlaneOf(fastened, drawn(fastened), 'b'), 0, 'its fastening places it')
94
+ })
95
+
96
+ /*
97
+ A part whose height is placed by a rule keeps nothing a handle could keep (kept 'none'): only its size is written. The
98
+ drag draws exactly that, frame by frame -- it does not look held at the opposite side and then land elsewhere (ADR-0097
99
+ §4.3: a drag whose frames differ from its release is not done; chief architect 10-20).
100
+ */
101
+ test('a position placed by a rule: each frame draws what the release writes -- the size alone, kept none', () => {
102
+ /* A command's result is sealed (ADR-0119): the rule goes into a copy. Its height now reads its x. */
103
+ const ruled = structuredClone(figure()) as FigureAsset
104
+ ;(ruled.document.model as unknown as { nodes: Array<{ id: string; args: string[] }> }).nodes.find(n => n.id === 'box.pose')!.args[1] = 'box.position.x'
105
+ const keep: FigureSizeKeep = { keep: 'opposite', axis: 'y', side: 1 }
106
+ const drag = startSizeDrag(ruled, 'box', {}, keep)
107
+ for (const height of [160, 240]) {
108
+ const sizes = [set('box.size.y', height)]
109
+ const frame = drag.frame(sizes)
110
+ assert.deepEqual(figureSizeEditOf(ruled, 'box', sizes, keep), { changes: sizes, kept: 'none' }, `${height}: the size alone`)
111
+ assert.deepEqual(frame.changes, sizes, `${height}: the frame writes the same`)
112
+ assert.deepEqual(posesOf(partsAtFrame(drawn(ruled), frame.moved)), posesOf(committed(ruled, frame.changes)), `${height}: drawn as written`)
113
+ }
114
+ })
@@ -37,22 +37,21 @@ test("no layout yet: a form on the selected part's top, and the equipment preset
37
37
  assert.equal(layout, undefined)
38
38
  const faces = anchorFacesOf(form.anchors, compileFigureAsset(asset).evaluate().geometry as never)
39
39
  assert.deepEqual(faces, { fork: { width: 1200, depth: 900 } })
40
- const say = (key: string) => key
41
- const presets = slotPresetsOf(form.anchors, say)
40
+ const presets = slotPresetsOf(form.anchors)
42
41
  assert.deepEqual(presets.map(p => p.key)[0], 'single', 'a single slot first: where an author starts')
43
42
  assert.equal(presets.length, 9)
44
43
  const size = { ...faces.fork!, slotHeight: 1000 }
45
44
  const single = presets[0]!.build(size, {})
46
45
  assert.deepEqual(single.tiers[0]!, { tierNo: 1, anchorId: 'fork', mount: 'ON', position: { x: 0, y: 0, z: 0 }, rotation: 0, layout: { pattern: 'SINGLE', slot: { allowed: { width: 1200, depth: 900, height: 1000 } } } })
47
46
  assert.deepEqual(presets.find(p => p.key === 'floorBlock')!.params, [
48
- { key: 'rows', label: 'figure.label.slot-preset-param-rows', integer: true },
49
- { key: 'cols', label: 'figure.label.slot-preset-param-cols', integer: true },
50
- { key: 'stackLimit', label: 'figure.label.slot-preset-param-stackLimit', integer: true }
47
+ { key: 'rows', integer: true },
48
+ { key: 'cols', integer: true },
49
+ { key: 'stackLimit', integer: true }
51
50
  ])
52
51
  /* Two anchors: the twin fork stands one tier on each. A bottom anchor hangs its single slot. */
53
- const twin = slotPresetsOf([{ id: 'left', part: 'a', face: 'top' }, { id: 'right', part: 'b', face: 'top' }], say)
52
+ const twin = slotPresetsOf([{ id: 'left', part: 'a', face: 'top' }, { id: 'right', part: 'b', face: 'top' }])
54
53
  assert.deepEqual(twin.find(p => p.key === 'craneTwinFork')!.build(size, {}).tiers.map(t => t.anchorId), ['left', 'right'])
55
- assert.equal(slotPresetsOf([{ id: 'under', part: 'fork', face: 'bottom' }], say)[0]!.build(size, {}).tiers[0]!.mount, 'HANG')
54
+ assert.equal(slotPresetsOf([{ id: 'under', part: 'fork', face: 'bottom' }])[0]!.build(size, {}).tiers[0]!.mount, 'HANG')
56
55
  })
57
56
 
58
57
  test('the editor is told what figure-model asks beyond its own checks: every slot names a type', () => {
@@ -103,7 +102,7 @@ test('refusals are said from their details, never the kernel\'s sentence', () =>
103
102
  })
104
103
 
105
104
  test('the slot layout panel starts from presets that keep where they started as a record', () => {
106
- const [single] = slotPresetsOf([{ id: 'fork', part: 'fork', face: 'top' }], k => k)
105
+ const [single] = slotPresetsOf([{ id: 'fork', part: 'fork', face: 'top' }])
107
106
  assert.deepEqual(single!.build({ width: 1200, depth: 900, slotHeight: 1000 }, {}).preset, { key: 'single', size: { width: 1200, depth: 900, slotHeight: 1000 }, params: {} })
108
107
  })
109
108
 
@@ -115,3 +114,9 @@ test('the places words are in every language', () => {
115
114
  assert.ok(t[key], `${lang} ${key}`)
116
115
  }
117
116
  })
117
+
118
+ /* A preset's name is the shared editor's (label.slot-layout.preset.*): figure-ui keeps no second copy of it. */
119
+ test('the preset names are the slot layout editor\'s, not a copy here', () => {
120
+ for (const lang of ['en', 'es', 'ja', 'ko', 'ms', 'zh'])
121
+ assert.deepEqual(Object.keys(JSON.parse(read(`../translations/${lang}.json`))).filter(key => key.includes('slot-preset')), [], lang)
122
+ })