@things-factory/figure-ui 10.1.200 → 10.1.201

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 (73) hide show
  1. package/client/modeller/editor.ts +7 -3
  2. package/client/modeller/fasten-form.ts +18 -35
  3. package/client/modeller/fasten-geometry.ts +67 -115
  4. package/client/modeller/figure-fasten-dialog.ts +4 -12
  5. package/client/modeller/figure-links.ts +6 -7
  6. package/client/modeller/joint-drag.ts +5 -5
  7. package/client/modeller/links.ts +35 -99
  8. package/client/modeller/refusals.ts +2 -6
  9. package/dist-client/modeller/editor.js +7 -2
  10. package/dist-client/modeller/editor.js.map +1 -1
  11. package/dist-client/modeller/fasten-form.d.ts +5 -8
  12. package/dist-client/modeller/fasten-form.js +16 -28
  13. package/dist-client/modeller/fasten-form.js.map +1 -1
  14. package/dist-client/modeller/fasten-geometry.d.ts +20 -19
  15. package/dist-client/modeller/fasten-geometry.js +54 -113
  16. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  17. package/dist-client/modeller/figure-fasten-dialog.js +4 -12
  18. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -1
  20. package/dist-client/modeller/figure-links.js +6 -7
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/joint-drag.js +5 -5
  23. package/dist-client/modeller/joint-drag.js.map +1 -1
  24. package/dist-client/modeller/links.d.ts +19 -26
  25. package/dist-client/modeller/links.js +36 -94
  26. package/dist-client/modeller/links.js.map +1 -1
  27. package/dist-client/modeller/refusals.d.ts +1 -1
  28. package/dist-client/modeller/refusals.js +2 -6
  29. package/dist-client/modeller/refusals.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +6 -6
  32. package/test/ai-proposal.test.ts +1 -1
  33. package/test/browser/acceptance-page.js +11 -11
  34. package/test/browser/detail-page.js +1 -1
  35. package/test/browser/detail-tabs-page.js +1 -1
  36. package/test/browser/editing-page.js +1 -1
  37. package/test/browser/fix-alignment.check.mjs +3 -3
  38. package/test/browser/links.check.mjs +20 -8
  39. package/test/browser/modeller-page.js +3 -3
  40. package/test/browser/part-ids.check.mjs +2 -2
  41. package/test/browser/parts-page.js +2 -2
  42. package/test/browser/product-page.check.mjs +3 -3
  43. package/test/browser/product-page.js +1 -1
  44. package/test/browser/row-preview.check.mjs +2 -2
  45. package/test/browser/screen-page.js +3 -3
  46. package/test/browser/section-page.js +3 -3
  47. package/test/browser/section.check.mjs +4 -4
  48. package/test/clip-ai-proposal.test.ts +1 -1
  49. package/test/clip-consent.test.ts +1 -1
  50. package/test/command-preview.test.ts +8 -8
  51. package/test/face-mapping.test.ts +2 -2
  52. package/test/fasten-drawing.test.ts +14 -2
  53. package/test/fasten-form.test.ts +11 -3
  54. package/test/fasten-geometry.test.ts +22 -31
  55. package/test/fix-alignment.test.ts +16 -4
  56. package/test/gizmo.test.ts +2 -2
  57. package/test/joint-drag.test.ts +13 -2
  58. package/test/joint-fan.test.ts +1 -1
  59. package/test/joint-frames.test.ts +45 -23
  60. package/test/joints.ts +1 -1
  61. package/test/links.test.ts +68 -68
  62. package/test/live.test.ts +2 -2
  63. package/test/preview-size.test.ts +1 -1
  64. package/test/refusals.test.ts +18 -20
  65. package/test/resize-with-placement.test.ts +6 -6
  66. package/test/revision.test.ts +4 -5
  67. package/test/section-panel.test.ts +1 -1
  68. package/translations/en.json +9 -11
  69. package/translations/es.json +9 -11
  70. package/translations/ja.json +9 -11
  71. package/translations/ko.json +9 -11
  72. package/translations/ms.json +9 -11
  73. package/translations/zh.json +9 -11
@@ -41,7 +41,7 @@ test('크기 — 제 치수 · 인스턴스 크기 · 다른 부품의 치수를
41
41
  test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', () => {
42
42
  const asset = pair()
43
43
  assert.equal(fastenedTo(asset, 'block'), null)
44
- const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'plate', face: 'top' } })
44
+ const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'block', to: { part: 'plate', face: 'top' } })
45
45
  assert.equal(fastenedTo(fastened, 'block'), 'plate')
46
46
  assert.equal(fastenedTo(applyFigureAuthoring(fastened, { kind: 'detach', part: 'block' }), 'block'), null)
47
47
  const withDrum = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 50, z: 0 } })
@@ -69,7 +69,7 @@ test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는
69
69
 
70
70
  import { readFileSync } from 'node:fs'
71
71
  import { fileURLToPath } from 'node:url'
72
- import { alignRowsOf, choiceOf, followCommandOf, ownFacesOf } from '../client/modeller/links.ts'
72
+ import { alignRowsOf, choiceOf, followCommandOf } from '../client/modeller/links.ts'
73
73
  import { faceViewOf } from '../client/modeller/fasten-geometry.ts'
74
74
  import { moving } from './joints.ts'
75
75
 
@@ -244,8 +244,8 @@ test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한
244
244
  let asset = createFigureAsset('Probe')
245
245
  asset = addFigureBox(asset, { id: 'base', size: { x: 400, y: 100, z: 400 }, position: { x: 0, y: 0, z: 0 } })
246
246
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 0, z: 0 } })
247
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'base', face: 'bottom', to: { plane: 'mounting-plane' } })
248
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'base', face: 'top' }, align: { x: { mm: 120 } } })
247
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'base', to: { plane: 'mounting-plane' } })
248
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', to: { part: 'base', face: 'top' }, align: { x: { mm: 120 } } })
249
249
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 40, max: 4000 }, y: { min: 16, max: 1600 }, z: { min: 40, max: 4000 } } })
250
250
  assert.equal(proportionToFollow(asset), undefined, '한 비율이 아니면 권하지 않는다')
251
251
  asset = applyFigureAuthoring(asset, { kind: 'scale-uniformly' })
@@ -271,20 +271,20 @@ test('붙일 때 면 위 어디에 — 그 면을 가로지르는 두 축만,
271
271
  two = addFigureBox(two, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
272
272
  two = addFigureBox(two, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
273
273
  const right = { part: 'jamb', face: 'right' }
274
- assert.deepEqual(alignAxesOf(two, 'leaf', right), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
275
- assert.deepEqual(alignAxesOf(two, 'leaf', 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }])
274
+ assert.deepEqual(alignAxesOf(two, right), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
275
+ assert.deepEqual(alignAxesOf(two, 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }])
276
276
  /* A door on the jamb's right face: bottoms level, fronts level. The x choice is along the face and is not sent. */
277
- assert.deepEqual(alignOf(two, 'leaf', right, { x: 'max', y: 'min', z: 'max' }, {}), { y: 'min', z: 'max' })
278
- assert.deepEqual(alignOf(two, 'leaf', { part: 'jamb', face: 'top' }, { x: 'mm', z: 'centre' }, { x: 120 }), { x: { mm: 120 } })
279
- assert.equal(alignOf(two, 'leaf', 'plane', { x: 'min' }, {}), undefined, '바닥에는 맞출 모서리가 없다')
280
- assert.equal(alignOf(two, 'leaf', { part: 'jamb', face: 'top' }, {}, {}), undefined)
277
+ assert.deepEqual(alignOf(two, right, { x: 'max', y: 'min', z: 'max' }, {}), { y: 'min', z: 'max' })
278
+ assert.deepEqual(alignOf(two, { part: 'jamb', face: 'top' }, { x: 'mm', z: 'centre' }, { x: 120 }), { x: { mm: 120 } })
279
+ assert.equal(alignOf(two, 'plane', { x: 'min' }, {}), undefined, '바닥에는 맞출 모서리가 없다')
280
+ assert.equal(alignOf(two, { part: 'jamb', face: 'top' }, {}, {}), undefined)
281
281
 
282
282
  /* What the form sends is what hangs the part on the edge: the door's pivot is on the jamb's front edge line. */
283
283
  let asset = createFigureAsset('Door')
284
284
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
285
285
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
286
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
287
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'left', to: { part: 'jamb', face: 'right' }, align: alignOf(asset, 'leaf', { part: 'jamb', face: 'right' }, { y: 'min', z: 'max' }, {}) })
286
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
287
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: alignOf(asset, { part: 'jamb', face: 'right' }, { y: 'min', z: 'max' }, {}) })
288
288
  const { compileFigureAsset } = await import('@hatiolab/figure-model')
289
289
  const leaf = compileFigureAsset(asset).evaluate().geometry.find(g => g.id === 'leaf')!
290
290
  assert.deepEqual(leaf.pose.t.map(Math.round), [500, 1000, 55], '문짝 아래가 문틀 아래에, 앞이 문틀 앞에')
@@ -295,13 +295,13 @@ test('the fastening form asks across the face in rows only where the drawing can
295
295
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
296
296
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
297
297
  const right = { part: 'jamb', face: 'right' }
298
- const view = faceViewOf(revisionOf(asset), 'leaf', right)
298
+ const view = faceViewOf(revisionOf(asset), 'leaf', right, 'meet')
299
299
  assert.deepEqual(view?.axes, ['y', 'z'])
300
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, view, false), [], 'the drawing sets both')
301
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, undefined, false), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }], 'with no drawing, both are asked')
302
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, view, false, { y: 'leaf.position.y' }), [{ axis: 'y', edges: true }], 'the axis that follows a position field')
300
+ assert.deepEqual(alignRowsOf(asset, right, view), [], 'the drawing sets both')
301
+ assert.deepEqual(alignRowsOf(asset, right, undefined), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }], 'with no drawing, both are asked')
302
+ assert.deepEqual(alignRowsOf(asset, right, view, { y: 'leaf.position.y' }), [{ axis: 'y', edges: true }], 'the axis that follows a position field')
303
303
  const sized = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 100, max: 5000 }, y: { min: 100, max: 5000 }, z: { min: 10, max: 500 } }, at: { x: 1000, y: 2100, z: 150 } })
304
- assert.deepEqual(alignRowsOf(sized, 'leaf', right, faceViewOf(revisionOf(sized), 'leaf', right), true).map(r => r.axis), ['y', 'z'], 'the axes a share of the figure size may set')
304
+ assert.deepEqual(alignRowsOf(sized, right, faceViewOf(revisionOf(sized), 'leaf', right, 'meet')).map(r => r.axis), ['y', 'z'], 'the axes a share of the figure size may set')
305
305
  })
306
306
 
307
307
  test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서 거리로 채운다 (문짝이 문틀 앞 100 mm 에서 가운데로 옮겨졌다, 09-26)', async () => {
@@ -313,9 +313,9 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
313
313
  const where = (a: FigureAsset) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'leaf')!.pose.t.map(v => Math.round(v))
314
314
  for (const target of ['plane', { part: 'frame', face: 'top' }] as const) {
315
315
  const kept = placeKeptOf(revisionOf(asset), 'leaf', target)
316
- const align = alignOf(asset, 'leaf', target, kept.align, kept.alignMm)
316
+ const align = alignOf(asset, target, kept.align, kept.alignMm)
317
317
  const to = target === 'plane' ? { plane: 'mounting-plane' as const } : target
318
- const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'bottom', to, ...(align ? { align } : {}) } as never)
318
+ const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to, ...(align ? { align } : {}) } as never)
319
319
  const [before, after] = [where(asset), where(fastened)]
320
320
  assert.deepEqual([after[0], after[2]], [before[0], before[2]], `${JSON.stringify(target)}: 앞뒤·좌우는 그대로`)
321
321
  }
@@ -328,8 +328,8 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
328
328
  let row = addFigureBox(createFigureAsset('Row'), { id: 'plate', size: { x: 1000, y: 50, z: 400 }, position: { x: 0, y: 700, z: 0 } })
329
329
  row = addFigureBox(row, { id: 'roller', size: { x: 60, y: 60, z: 400 }, position: { x: 0, y: 760, z: 0 } })
330
330
  row = addFigureBox(row, { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 800, z: 0 } })
331
- row = applyFigureAuthoring(row, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'plate', face: 'top' } })
332
- row = applyFigureAuthoring(row, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
331
+ row = applyFigureAuthoring(row, { kind: 'attach', part: 'roller', to: { part: 'plate', face: 'top' } })
332
+ row = applyFigureAuthoring(row, { kind: 'attach', part: 'cap', to: { part: 'roller', face: 'top' } })
333
333
  assert.deepEqual([hangsFrom(row, 'roller', 'plate'), hangsFrom(row, 'cap', 'plate'), hangsFrom(row, 'plate', 'roller')], [true, true, false])
334
334
  })
335
335
 
@@ -340,10 +340,10 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
340
340
  asset = addFigureBox(asset, { id: 'palm', size: { x: 240, y: 70, z: 160 }, position: { x: 0, y: 35, z: 0 } })
341
341
  for (const id of ['left', 'right']) asset = addFigureBox(asset, { id, size: { x: 30, y: 140, z: 100 }, position: { x: 0, y: 200, z: 0 } })
342
342
  asset = addFigureBox(asset, { id: 'wrist', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 400, z: 0 } })
343
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'palm', face: 'bottom', to: { plane: 'mounting-plane' } })
344
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'left', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'min' } })
345
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'right', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'max' } })
346
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'wrist', face: 'top', to: { part: 'palm', face: 'bottom' } })
343
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'palm', to: { plane: 'mounting-plane' } })
344
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'left', to: { part: 'palm', face: 'top' }, align: { x: 'min' } })
345
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'right', to: { part: 'palm', face: 'top' }, align: { x: 'max' } })
346
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'wrist', to: { part: 'palm', face: 'bottom' } })
347
347
  const form = { kind: 'slide' as const, axis: 'x' as const, id: 'grip', min: 0, max: 60, follow: '', multiplier: 1, offset: 0 }
348
348
  const jointOf = (part: string) => asset.joints!.find(j => j.body1.part === part)!
349
349
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf('left'), form, 'left') })
@@ -385,8 +385,8 @@ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구
385
385
  assert.equal(canFasten(asset, 'ball'), false, '공은 붙일 면이 없다')
386
386
  assert.equal(canFasten(asset, 'frame'), true)
387
387
  assert.deepEqual(facesOf(asset, 'cone'), ['bottom', 'top'])
388
- assert.deepEqual(alignAxesOf(asset, 'leaf', { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
389
- assert.deepEqual(alignAxesOf(asset, 'leaf', { part: 'cone', face: 'top' }), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '옆면이 평면이 아니면 모서리도 없다')
388
+ assert.deepEqual(alignAxesOf(asset, { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
389
+ assert.deepEqual(alignAxesOf(asset, { part: 'cone', face: 'top' }), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '옆면이 평면이 아니면 모서리도 없다')
390
390
  })
391
391
 
392
392
  test('되풀이는 붙어 있고 움직이지 않고 아무것도 매달지 않은 부품에 — 붙인 면을 가로지르는 축으로 (빈틈 B, 2026-09-25)', async () => {
@@ -395,24 +395,30 @@ test('되풀이는 붙어 있고 움직이지 않고 아무것도 매달지 않
395
395
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
396
396
  asset = addFigureBox(asset, { id: 'slat', size: { x: 60, y: 20, z: 560 }, position: { x: 0, y: 0, z: 0 } })
397
397
  assert.deepEqual(rowAxesOf(asset, 'slat'), [], '붙지 않은 부품은 되풀이를 권하지 않는다')
398
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
399
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', face: 'bottom', to: { part: 'frame', face: 'top' } })
398
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
399
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', to: { part: 'frame', face: 'top' } })
400
400
  assert.deepEqual(rowAxesOf(asset, 'frame'), [], '바닥에 선 부품은 채울 길이가 없다')
401
401
  assert.deepEqual(rowAxesOf(asset, 'slat'), ['x', 'z'], '윗면을 가로지르는 두 축')
402
+ assert.deepEqual(rowAxesOf(applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', to: { part: 'frame', face: 'front' }, replace: true }), 'slat'), ['x', 'y'], '앞면이면 앞면을 가로지르는 두 축')
402
403
  asset = applyFigureAuthoring(asset, { kind: 'make-repeat', part: 'slat', axis: 'x', pitch: 100 })
403
404
  assert.deepEqual(rowOf(asset, 'slat'), { axis: 'X', pitch: 100 })
404
405
  assert.deepEqual(rowAxesOf(asset, 'slat'), [], '이미 되풀이면 다시 권하지 않는다')
405
406
  })
406
407
 
407
- test('비튼 부품에는 모서리 맞춤을 내놓지도 보내지도 않는다 — 가운데서 거리만 (빈틈 F, 2026-09-25)', async () => {
408
+ test('돌려 놓인 부품에도 모서리 맞춤을 내놓는다 — 지금 자세의 실제 끝으로 맞춘다 (ADR-0140 3.4)', async () => {
408
409
  const { alignAxesOf, alignOf } = await import('../client/modeller/links.ts')
409
410
  let asset = createFigureAsset('Fan')
410
411
  asset = addFigureBox(asset, { id: 'hub', size: { x: 120, y: 120, z: 80 }, position: { x: 0, y: 0, z: 0 } })
411
- asset = addFigureBox(asset, { id: 'blade', size: { x: 300, y: 60, z: 8 }, position: { x: 0, y: 0, z: 0 } })
412
+ asset = addFigureBox(asset, { id: 'blade', size: { x: 300, y: 60, z: 8 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 30 } })
412
413
  const front = { part: 'hub', face: 'front' }
413
- assert.deepEqual(alignAxesOf(asset, 'blade', front, true), [{ axis: 'x', edges: false }, { axis: 'y', edges: false }])
414
- assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max', y: 'mm' }, { y: 50 }, true), { y: { mm: 50 } }, '끝 맞춤은 빠지고 거리만 간다')
415
- assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max' }, {}), { x: 'max' }, '비틀지 않으면 그대로')
414
+ assert.deepEqual(alignAxesOf(asset, front), [{ axis: 'x', edges: true }, { axis: 'y', edges: true }])
415
+ assert.deepEqual(alignOf(asset, front, { x: 'max', y: 'mm' }, { y: 50 }), { x: 'max', y: { mm: 50 } })
416
+ /* Fastened so, its real reach is level with the hub's right side, turned as it is. */
417
+ const { compileFigureAsset, figureFaceOverhangOf } = await import('@hatiolab/figure-model')
418
+ const on = applyFigureAuthoring(asset, { kind: 'attach', part: 'blade', to: front, align: { x: 'max' } })
419
+ assert.equal(figureFaceOverhangOf(on, 'blade')!.state, 'larger', 'wider than the hub: past its left edge, level with its right')
420
+ assert.deepEqual(figureFaceOverhangOf(on, 'blade')!.sides.filter(s => s.axis === 'x').map(s => s.side), ['low'])
421
+ void compileFigureAsset
416
422
  })
417
423
 
418
424
  test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선언한다 — 그 뒤에야 부품이 도형 크기를 따를 수 있다 (사용자 09-25)', async () => {
@@ -421,7 +427,7 @@ test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선
421
427
  let asset = createFigureAsset('Conveyor')
422
428
  assert.equal(instanceRangeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
423
429
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
424
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
430
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
425
431
  assert.equal(hasInstanceSize(asset, 'x'), false, '새 도형에는 도형 크기가 없다')
426
432
  const toDeclare = instanceRangeToDeclare(asset)!
427
433
  assert.deepEqual(toDeclare.at, { x: 2000, y: 100, z: 600 }, '지금 부품의 크기가 선언할 크기')
@@ -456,8 +462,8 @@ test('미끄러짐의 이동 거리가 부품 길이를 따른다 — 레일이
456
462
  let asset = createFigureAsset('Gantry')
457
463
  asset = addFigureBox(asset, { id: 'rail', size: { x: 3000, y: 120, z: 120 }, position: { x: 0, y: 0, z: 0 } })
458
464
  asset = addFigureBox(asset, { id: 'bridge', size: { x: 160, y: 160, z: 1300 }, position: { x: 0, y: 0, z: 0 } })
459
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'rail', face: 'bottom', to: { plane: 'mounting-plane' } })
460
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bridge', face: 'bottom', to: { part: 'rail', face: 'top' }, align: { x: 'min' } })
465
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'rail', to: { plane: 'mounting-plane' } })
466
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bridge', to: { part: 'rail', face: 'top' }, align: { x: 'min' } })
461
467
  const joint = asset.joints!.find(j => j.body1.part === 'bridge')!
462
468
  const form = { kind: 'slide' as const, axis: 'x' as const, id: 'x', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: 'rail:x', travelPlus: -160 }
463
469
  const record = motionJoint(joint, form, 'bridge')
@@ -480,14 +486,14 @@ test('면 위 거리를 도형 크기 비율로 — 도형이 커지면 그 자
480
486
  let asset = createFigureAsset('Panel')
481
487
  asset = addFigureBox(asset, { id: 'panel', size: { x: 1000, y: 800, z: 40 }, position: { x: 0, y: 0, z: 0 } })
482
488
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 60, y: 60, z: 30 }, position: { x: 0, y: 0, z: 0 } })
483
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'panel', face: 'bottom', to: { plane: 'mounting-plane' } })
489
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'panel', to: { plane: 'mounting-plane' } })
484
490
  const front = { part: 'panel', face: 'front' }
485
- assert.deepEqual(alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 }), undefined, '도형 크기가 없으면 비율을 보낼 수 없다')
491
+ assert.deepEqual(alignOf(asset, front, { x: 'share' }, { x: 300 }), undefined, '도형 크기가 없으면 비율을 보낼 수 없다')
486
492
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 200, max: 5000 }, y: { min: 200, max: 5000 }, z: { min: 10, max: 500 } }, at: { x: 1000, y: 800, z: 40 } })
487
493
  asset = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'panel', axis: 'x', source: { of: 'instance', axis: 'x' } })
488
- const align = alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 })!
494
+ const align = alignOf(asset, front, { x: 'share' }, { x: 300 })!
489
495
  assert.deepEqual(align, { x: { mm: { from: { of: 'instance', axis: 'x' }, times: 0.3 } } })
490
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', face: 'back', to: front, align })
496
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', to: front, align })
491
497
  const x = (size: number) => Math.round(compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, 'size.x': size } }).evaluate().geometry.find(g => g.id === 'lamp')!.pose.t[0]!)
492
498
  assert.equal(x(1000), 300)
493
499
  assert.equal(x(2000), 600, '도형이 두 배면 거리도 두 배')
@@ -498,8 +504,8 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
498
504
  let asset = createFigureAsset('Door')
499
505
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
500
506
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
501
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
502
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'left', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
507
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
508
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
503
509
  const j = asset.joints!.find(o => o.id === 'leaf')!
504
510
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
505
511
  assert.deepEqual(instanceRangeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
@@ -508,15 +514,15 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
508
514
  test('바닥에 선 부품도 도형 크기를 선언한 도형이면 도형의 옆면에 맞출 수 있다 (2026-09-25)', async () => {
509
515
  const { alignAxesOf, alignOf } = await import('../client/modeller/links.ts')
510
516
  let asset = addFigureBox(createFigureAsset('Gate'), { id: 'post', size: { x: 100, y: 2000, z: 150 }, position: { x: 0, y: 0, z: 0 } })
511
- assert.deepEqual(alignAxesOf(asset, 'post', 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '크기가 없으면 옆면도 없다')
517
+ assert.deepEqual(alignAxesOf(asset, 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '크기가 없으면 옆면도 없다')
512
518
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 200, max: 5000 }, y: { min: 500, max: 5000 }, z: { min: 50, max: 500 } }, at: { x: 1100, y: 2000, z: 150 } })
513
- assert.deepEqual(alignAxesOf(asset, 'post', 'plane'), [{ axis: 'x', edges: true }, { axis: 'z', edges: true }])
514
- assert.deepEqual(alignOf(asset, 'post', 'plane', { x: 'min' }, {}), { x: 'min' })
519
+ assert.deepEqual(alignAxesOf(asset, 'plane'), [{ axis: 'x', edges: true }, { axis: 'z', edges: true }])
520
+ assert.deepEqual(alignOf(asset, 'plane', { x: 'min' }, {}), { x: 'min' })
515
521
  })
516
522
 
517
523
  test('움직임은 그 자리에서 고친다 — 폼에 지금 관절을 채우고, 그대로 보내면 같은 관절이다 (2026-09-25)', async () => {
518
524
  const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
519
- const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b', face: 'bottom' as const } }
525
+ const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b' } }
520
526
  const joints = [
521
527
  { ...base, type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } },
522
528
  { ...base, type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } },
@@ -540,25 +546,21 @@ test('바닥에 선 부품도 무엇에 붙어 있는지 읽는다 — 그래야
540
546
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
541
547
  asset = addFigureBox(asset, { id: 'head', size: { x: 1100, y: 100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
542
548
  assert.equal(heldBy(asset, 'jamb'), null)
543
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
544
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'head', face: 'bottom', to: { part: 'jamb', face: 'top' } })
549
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
550
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'head', to: { part: 'jamb', face: 'top' } })
545
551
  assert.deepEqual(heldBy(asset, 'jamb'), { plane: true }, '장착 평면')
546
552
  assert.deepEqual(heldBy(asset, 'head'), { part: 'jamb' })
547
553
  /* What the form then sends: a fastening that replaces the one there. */
548
- assert.doesNotThrow(() => applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' }, align: { x: { mm: -500 } }, replace: !!heldBy(asset, 'jamb') }))
554
+ assert.doesNotThrow(() => applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' }, align: { x: { mm: -500 } }, replace: !!heldBy(asset, 'jamb') }))
549
555
  })
550
556
 
551
- test('벽 배치 — 장착 평면은 Z = 0, 등으로 붙이고 가로질러 X · Y 로 맞춘다(사용자 · 아키텍트 09-25)', async () => {
557
+ test('벽 배치 — 장착 평면은 Z = 0, 벽에서 나와 서고 가로질러 X · Y 로 맞춘다(사용자 · 아키텍트 09-25, ADR-0140 3.11)', async () => {
552
558
  const { mountingPlaneOf, alignAxesOf } = await import('../client/modeller/links.ts')
553
559
  let a = addFigureBox(createFigureAsset('Lamp'), { id: 'plate', size: { x: 200, y: 300, z: 20 }, position: { x: 0, y: 0, z: 0 } })
554
- assert.deepEqual(mountingPlaneOf(a), { normal: 'y', faces: ['bottom', 'top'] }, '배치 선언 전에는 바닥')
560
+ assert.deepEqual(mountingPlaneOf(a), { normal: 'y', facings: ['meet', 'flush'] }, '배치 선언 전에는 바닥 — 올려 서거나 아래에 매달림')
555
561
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: (await import('@hatiolab/figure-model')).proposeFigureOccupancy(a), placement: 'wall' as never })
556
- assert.deepEqual(mountingPlaneOf(a), { normal: 'z', faces: ['back'] })
557
- assert.deepEqual(alignAxesOf(a, 'plate', 'plane').map(x => x.axis), ['x', 'y'])
558
- assert.deepEqual(ownFacesOf(a, 'plate', true), ['back'], 'on the plane, only the face the plane takes')
559
- assert.deepEqual(ownFacesOf(a, 'plate', false), ['left', 'right', 'bottom', 'top', 'back', 'front', 'centre'], 'to a part, its six outer faces and its centre')
560
- const floor = addFigureBox(createFigureAsset('Floor'), { id: 'plate', size: { x: 200, y: 300, z: 20 }, position: { x: 0, y: 0, z: 0 } })
561
- assert.deepEqual(ownFacesOf(floor, 'plate', true), ['bottom', 'top'], 'on the floor, its bottom or its top')
562
+ assert.deepEqual(mountingPlaneOf(a), { normal: 'z', facings: ['meet'] })
563
+ assert.deepEqual(alignAxesOf(a, 'plane').map(x => x.axis), ['x', 'y'])
562
564
  })
563
565
 
564
566
  test('부품 칸의 두 제목이 같은 말이 아니다 — 자리 숫자는 「위치와 크기」, 붙이기 칸은 「붙이기」 (09-26)', () => {
@@ -578,8 +580,8 @@ test('보이는 때 — 명령이 남긴 것을 읽고, 폼은 그 명령을 낸
578
580
  asset = addFigureBox(asset, { id: 'frame', size: { x: 1200, y: 60, z: 200 }, position: { x: 0, y: 30, z: 0 } })
579
581
  asset = addFigureBox(asset, { id: 'door', size: { x: 1000, y: 2000, z: 40 }, position: { x: 0, y: 1060, z: 0 } })
580
582
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 2200, z: 0 } })
581
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
582
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'door', face: 'bottom', to: { part: 'frame', face: 'top' } })
583
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
584
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'door', to: { part: 'frame', face: 'top' } })
583
585
  assert.deepEqual(visibilityControlsOf(asset), [], '조작값이 없으면 고를 것도 없다')
584
586
  asset = moving(asset, 'door', { kind: 'slide', axis: 'x', id: 'gate-open', min: 0, max: 900 })
585
587
  assert.deepEqual(visibilityControlsOf(asset).map(c => [c.id, c.min, c.max, c.unit]), [['gate-open', 0, 900, 'mm']])
@@ -629,20 +631,18 @@ import { wordsOf } from './words.ts'
629
631
 
630
632
  test('붙이기 칸은 부품 제 붙임으로 열린다 — 가운데로 붙은 롤러는 가운데 · 상대 · 간격 그대로', () => {
631
633
  let asset = addFigureBox(createFigureAsset('Rollers'), { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
632
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
634
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', to: { plane: 'mounting-plane' } })
633
635
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 40, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
634
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
636
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', facing: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
635
637
  const roller = fasteningOf(revisionOf(asset), 'roller')!
636
- assert.equal(roller.face, 'centre')
638
+ assert.equal(roller.facing, 'centre')
637
639
  assert.equal(roller.target, 'bed')
638
640
  assert.equal(roller.targetFace, 'top')
639
641
  assert.equal(roller.gap, 40)
640
642
  assert.equal(roller.align.x, 'min', '기록의 정렬')
641
643
  const bed = fasteningOf(revisionOf(asset), 'bed')!
642
- assert.deepEqual([bed.face, bed.toPlane], ['bottom', true])
644
+ assert.deepEqual([bed.facing, bed.toPlane], ['meet', true])
643
645
  assert.equal(fasteningOf(revisionOf(addFigureBox(createFigureAsset('Loose'), { id: 'b', size: { x: 1, y: 1, z: 1 }, position: { x: 0, y: 0, z: 0 } })), 'b'), null, '안 붙은 부품은 기본값으로')
644
- assert.equal(ownFacesOf(asset, 'roller', false).at(-1), 'centre', 'its own faces include its centre')
645
- assert.ok(!ownFacesOf(asset, 'roller', true).includes('centre'), 'on the floor, by a face only')
646
646
  })
647
647
 
648
648
  test('a worked-out gap is kept by the fastening form: opening it on the part moves nothing (the robot dog, the forklift, 2026-10-13)', async () => {
@@ -652,7 +652,7 @@ test('a worked-out gap is kept by the fastening form: opening it on the part mov
652
652
  Object.fromEntries((compileFigureAsset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { t: number[] } }>).map(g => [g.id, g.pose?.t.map(v => Math.round(v * 1000) / 1000)]))
653
653
  /* What the form sends when nothing in it is changed: the attach it would make of the part's own fastening. */
654
654
  const reattach = (asset: FigureAsset, part: string, form: Parameters<typeof fastenGapOf>[0]) =>
655
- applyFigureAuthoring(asset, { kind: 'attach', part, face: 'bottom', to: { plane: 'mounting-plane' }, ...fastenGapOf(form), twist: 0, replace: true } as never)
655
+ applyFigureAuthoring(asset, { kind: 'attach', part, to: { plane: 'mounting-plane' }, ...fastenGapOf(form), replace: true } as never)
656
656
 
657
657
  const dog = robotDogFigure().asset
658
658
  const body = fasteningOf(revisionOf(dog), 'body')!
@@ -665,7 +665,7 @@ test('a worked-out gap is kept by the fastening form: opening it on the part mov
665
665
  let lift = addFigureBox(createFigureAsset('Lift'), { id: 'body', size: { x: 1000, y: 800, z: 600 }, position: { x: 0, y: 500, z: 0 } })
666
666
  lift.document.model.inputs.push({ id: 'size.y', unit: 'mm', min: 100, max: 20000, role: 'design' } as never)
667
667
  lift.designInputs['size.y'] = 1800
668
- lift = applyFigureAuthoring(lift, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' }, gap: { from: { of: 'instance', axis: 'y' }, times: 0.05 } } as never)
668
+ lift = applyFigureAuthoring(lift, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' }, gap: { from: { of: 'instance', axis: 'y' }, times: 0.05 } } as never)
669
669
  const kept = fasteningOf(revisionOf(lift), 'body')!
670
670
  assert.deepEqual(kept.gapKept, { from: { of: 'instance', axis: 'y' }, times: 0.05 })
671
671
  assert.deepEqual(posesOf(reattach(lift, 'body', kept)), posesOf(lift))
package/test/live.test.ts CHANGED
@@ -70,9 +70,9 @@ test('끄는 동안은 세션이 지금 든 자세 위에 그린다 — 다른
70
70
  let asset = slider()
71
71
  /* An arm on a post of its own, turning about the post's left edge -- so a swing moves it, and the slide does not. */
72
72
  asset = addFigureBox(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 400, y: 50, z: 0 } })
73
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' }, align: { x: { mm: 400 } } })
73
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'post', to: { plane: 'mounting-plane' }, align: { x: { mm: 400 } } })
74
74
  asset = addFigureBox(asset, { id: 'arm', size: { x: 100, y: 20, z: 20 }, position: { x: 400, y: 110, z: 0 } })
75
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'arm', face: 'bottom', to: { part: 'post', face: 'top' }, align: { x: 'min' } })
75
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'arm', to: { part: 'post', face: 'top' }, align: { x: 'min' } })
76
76
  asset = moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'swing', min: -180, max: 180 })
77
77
  const armAt = (parts: { id: string; pose: { t: number[] } }[]) => parts.find(p => p.id === 'arm')!.pose.t.map(v => Math.round(v))
78
78
  const committed = armAt(compileFigureAsset({ ...asset, stateDefaults: { ...asset.stateDefaults, swing: 90 } }).evaluate().geometry as never)
@@ -13,7 +13,7 @@ import { previewSizeWordsOf, rowCountsAt } from '../client/modeller/preview-size
13
13
  function conveyor(): FigureAsset {
14
14
  let asset = addFigureBox(createFigureAsset('Conveyor'), { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
15
15
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
16
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
16
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', to: { part: 'frame', face: 'top' } })
17
17
  asset = applyFigureAuthoring(asset, { kind: 'follow-size', part: 'frame', axes: ['x'] })
18
18
  return applyFigureAuthoring(asset, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
19
19
  }
@@ -61,8 +61,8 @@ function conveyor(): FigureAsset {
61
61
  let a = fm.createFigureAsset('Conveyor')
62
62
  a = fm.addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
63
63
  a = fm.addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 0, z: 0 } })
64
- a = act(a, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
65
- a = act(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
64
+ a = act(a, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
65
+ a = act(a, { kind: 'attach', part: 'roller', to: { part: 'frame', face: 'top' } })
66
66
  return act(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
67
67
  }
68
68
 
@@ -100,10 +100,10 @@ test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그
100
100
  a = fm.addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 0, z: 0 } })
101
101
  a = fm.addFigureBox(a, { id: 'lamp', size: { x: 80, y: 80, z: 80 }, position: { x: 0, y: 0, z: 0 } })
102
102
  a = fm.addFigureBox(a, { id: 'shade', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 0, z: 0 } })
103
- a = act(a, { kind: 'attach', part: 'leg', face: 'bottom', to: { plane: 'mounting-plane' } })
104
- a = act(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'leg', face: 'top' } })
105
- a = act(a, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
106
- a = act(a, { kind: 'attach', part: 'shade', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
103
+ a = act(a, { kind: 'attach', part: 'leg', to: { plane: 'mounting-plane' } })
104
+ a = act(a, { kind: 'attach', part: 'top', to: { part: 'leg', face: 'top' } })
105
+ a = act(a, { kind: 'attach', part: 'lamp', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
106
+ a = act(a, { kind: 'attach', part: 'shade', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
107
107
  const turn = (asset: typeof a, part: string, extra: object) => act(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, type: 'revolute', axis: 'Y', ...extra } })
108
108
  a = turn(a, 'lamp', { state: { id: 'lamp-turn' } })
109
109
  a = turn(a, 'shade', { mimic: { joint: 'lamp', multiplier: 1 } })
@@ -121,15 +121,15 @@ test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그
121
121
  assert.match(why(declared, 'shade')!, /part-in-declared-space .*"part":"shade"/, '잎인데 공간 선언에 들어 있다')
122
122
  assert.match(why(conveyor(), 'frame')!, /part-row-along .*"part":"frame".*"rows":"roller"/, '줄이 따라 놓인 부품')
123
123
  let hung = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
124
- hung = act(hung, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
124
+ hung = act(hung, { kind: 'attach', part: 'cap', to: { part: 'roller', face: 'top' } })
125
125
  assert.match(why(hung, 'roller')!, /part-row-carries .*"part":"roller".*"others":"cap"/, '줄에 매달린 것')
126
126
  })
127
127
 
128
128
  test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은 부품에 다시 맞추며 그렇다고 한 줄 말한다(묻지 않는다, 2026-09-27)', () => {
129
129
  let a = fm.addFigureBox(fm.createFigureAsset('Gate'), { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 0, z: 0 } })
130
130
  a = fm.addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
131
- a = act(a, { kind: 'attach', part: 'housing', face: 'bottom', to: { plane: 'mounting-plane' } })
132
- a = act(a, { kind: 'attach', part: 'stray', face: 'bottom', to: { plane: 'mounting-plane' } })
131
+ a = act(a, { kind: 'attach', part: 'housing', to: { plane: 'mounting-plane' } })
132
+ a = act(a, { kind: 'attach', part: 'stray', to: { plane: 'mounting-plane' } })
133
133
  a = act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'floor' })
134
134
  assert.equal(inDeclaredRoom(refusalOf(() => fm.editFigurePart(a, { kind: 'remove-part', target: 'stray' }))), true)
135
135
  assert.equal(inDeclaredRoom(refusalOf(() => fm.editFigurePart(conveyor(), { kind: 'remove-part', target: 'frame' }))), false, '줄은 이 길이 아니다')
@@ -144,12 +144,11 @@ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평
144
144
  const a = act(fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } }), {
145
145
  kind: 'attach',
146
146
  part: 'post',
147
- face: 'bottom',
148
147
  to: { plane: 'mounting-plane' }
149
148
  })
150
149
  const refused = refusalOf(() => act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' }))
151
150
  const said = placementRefusalOf(refused, say)!
152
- assert.ok(said.includes('post (bottom)') && said.includes('벽') && said.includes('back'), said)
151
+ assert.equal(said, '장착 평면에 붙은 부품이 있어 놓이는 곳을 바꿀 수 없습니다: post. 먼저 이 부품을 떼어 주십시오.')
153
152
  assert.equal(editRefusalOf(refused, say), said, '편집 거절의 말도 같다')
154
153
  assert.equal(placementRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say), null)
155
154
  /* The placement list sends its command through the page: said there in words, and the list back on the placement it has (product-page.check.mjs). */
@@ -191,13 +190,13 @@ test('줄이 한도를 넘으면 몇 개가 되는지와 한도를 말한다 —
191
190
  let a = fm.createFigureAsset('Conveyor')
192
191
  a = fm.addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
193
192
  a = fm.addFigureBox(a, { id: 'roller', size: { x: 10, y: 60, z: 560 }, position: { x: 0, y: 0, z: 0 } })
194
- a = act(a, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
195
- a = act(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
193
+ a = act(a, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
194
+ a = act(a, { kind: 'attach', part: 'roller', to: { part: 'frame', face: 'top' } })
196
195
  const many = editRefusalOf(refusalOf(() => act(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 12, count: 150 })), say)
197
196
  assert.ok(many.includes('150') && many.includes('1~100') && !many.includes('GEOMETRY_DOMAIN'), many)
198
197
  /* A saved figure whose row would expand past what a figure draws: ten thousand copies, each a roller and its cap. */
199
198
  let capped = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
200
- capped = structuredClone(act(capped, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } }))
199
+ capped = structuredClone(act(capped, { kind: 'attach', part: 'cap', to: { part: 'roller', face: 'top' } }))
201
200
  const m = capped.document.model as unknown as { constants: Array<{ id: string; value: number }> }
202
201
  m.constants.find(c => c.id === 'limit.count.100')!.value = 10000
203
202
  m.constants.find(c => c.id === 'd.mm.200')!.value = 0.2
@@ -209,15 +208,15 @@ test('일상에서 코드로만 보이던 거절 넷 — 줄이 안 들어감 ·
209
208
  const shelf = () => {
210
209
  let a = fm.addFigureBox(fm.createFigureAsset('Shelf'), { id: 'board', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 0, z: 0 } })
211
210
  a = fm.addFigureBox(a, { id: 'bin', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 0, z: 0 } })
212
- a = act(a, { kind: 'attach', part: 'board', face: 'bottom', to: { plane: 'mounting-plane' } })
213
- return act(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { part: 'board', face: 'top' } })
211
+ a = act(a, { kind: 'attach', part: 'board', to: { plane: 'mounting-plane' } })
212
+ return act(a, { kind: 'attach', part: 'bin', to: { part: 'board', face: 'top' } })
214
213
  }
215
214
  /* Five 100 mm bins asked for on 400 mm. */
216
215
  const fit = editRefusalOf(refusalOf(() => act(shelf(), { kind: 'make-repeat', part: 'bin', axis: 'x', pitch: 1, count: 5 })), keyed)
217
216
  assert.match(fit, /row-does-not-fit .*"count":"5".*"length":"400".*"extent":"100"/)
218
217
  /* A cap on the roller made a row along X, as the roller's row is. */
219
218
  let capped = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
220
- capped = act(capped, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
219
+ capped = act(capped, { kind: 'attach', part: 'cap', to: { part: 'roller', face: 'top' } })
221
220
  assert.match(editRefusalOf(refusalOf(() => act(capped, { kind: 'make-repeat', part: 'cap', axis: 'x', pitch: 10 })), keyed), /row-same-axis .*"part":"cap","outer":"roller","axis":"X"/)
222
221
  /* The bin's top holds the slot layout: made a row, the layout would stand on no one bin. */
223
222
  const slotted = act(shelf(), {
@@ -243,7 +242,6 @@ test('옮겨 적은 거절 — 기록은 커널의 거절과 같은 모양이라
243
242
  const a = act(fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } }), {
244
243
  kind: 'attach',
245
244
  part: 'post',
246
- face: 'bottom',
247
245
  to: { plane: 'mounting-plane' }
248
246
  })
249
247
  const refused = refusalOf(() => act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' }))
@@ -271,7 +269,7 @@ test('단면 · 속 공간 — 모서리, 벽, 막힌 끝, 붙은 면을 거절
271
269
  assert.match(editRefusalOf(refusalOf(() => act(box(), { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: { closed: 200 }, end: { closed: 200 } } })), keyed), /^figure\.text\.section-ends-too-thick/)
272
270
  let tray = act(box(), { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: { closed: 15 }, end: 'open' } })
273
271
  tray = fm.addFigureBox(tray, { id: 'divider', size: { x: 10, y: 100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
274
- tray = act(tray, { kind: 'attach', part: 'divider', face: 'bottom', to: { part: 'box', face: 'inside-bottom' } })
272
+ tray = act(tray, { kind: 'attach', part: 'divider', to: { part: 'box', face: 'inside-bottom' } })
275
273
  const faces = editRefusalOf(refusalOf(() => act(tray, { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: 'open', end: 'open' } })), say)
276
274
  assert.ok(faces.includes(ko['figure.label.face-inside-bottom']), faces)
277
275
  /* A section finding the screen has no words for is said with its code. */
@@ -309,7 +307,7 @@ test('도형의 한도 — 노드 수, 저장 크기, 중첩 깊이를 숫자로
309
307
  })
310
308
 
311
309
  test('레시피가 막히면 몇 번째 단계에서 왜 막혔는지 사람의 말로 — 단계 안의 거절도 값을 잃지 않는다 (2026-09-27)', () => {
312
- const refused = refusalOf(() => fm.applyFigureProposal(box(), [{ kind: 'attach', part: 'nowhere', face: 'bottom', to: { plane: 'mounting-plane' } }, declaring({ min: 500, max: 4000 })] as never))
310
+ const refused = refusalOf(() => fm.applyFigureProposal(box(), [{ kind: 'attach', part: 'nowhere', to: { plane: 'mounting-plane' } }, declaring({ min: 500, max: 4000 })] as never))
313
311
  assert.match(recipeRefusalOf(refused, keyed)!, /^figure\.text\.recipe-step-refused .*"step":"1"/, '첫째 단계, 코드와 함께')
314
312
  const sized = refusalOf(() => fm.applyFigureProposal(box(), [declaring({ min: 500, max: 4000 })] as never))
315
313
  const told = recipeRefusalOf(sized, say)!
@@ -152,14 +152,14 @@ test('a fastened part keeps its position and turn rows, with the numbers the rul
152
152
 
153
153
  test('size response and attachment are separate screen paths', () => {
154
154
  const ko = JSON.parse(readFileSync(new URL('../translations/ko.json', import.meta.url), 'utf-8'))
155
- assert.equal(ko['figure.text.held-on-plane'].replace('{face}', '아랫면').replace('{place}', '바닥').replace('{gap}', '0'), '아랫면을 바닥에 유지 중 (간격 0 mm)')
155
+ assert.equal(ko['figure.text.held-on-plane'].replace('{place}', '바닥').replace('{gap}', '0'), '바닥에 붙어 있음 (간격 0 mm)')
156
156
  })
157
157
 
158
- test('a part fastened twisted has no face figure-model can keep for a drag, so the size gizmo is withheld', async () => {
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
159
  const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
160
160
  let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
161
161
  a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
162
- a = applyFigureAuthoring(a, { kind: 'attach', part: 'blade', face: 'bottom', to: { part: 'hub', face: 'top' }, twist: 30 })
162
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', type: 'fixed', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
163
163
  assert.equal(figureScaleAnchorOf(a, 'blade'), undefined)
164
164
  })
165
165
 
@@ -174,7 +174,7 @@ test('"open attachment settings" opens this part\'s fastening, as it is now, and
174
174
  const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box', gap: 'keep' }).steps)
175
175
  const form = fasteningOf(revisionOf(kept), 'box')!
176
176
  assert.equal(form.toPlane, true, 'on the plane')
177
- assert.equal(form.face, 'bottom')
177
+ assert.equal(form.facing, 'meet')
178
178
  assert.equal(form.gap, 100, 'the gap it keeps')
179
179
  })
180
180
 
@@ -189,8 +189,8 @@ test('the fastening form keeps a place that follows a position field, and fasten
189
189
  const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps)
190
190
  const form = fasteningOf(revisionOf(kept), 'box')!
191
191
  assert.deepEqual([form.align.x, form.alignInput.x, form.align.z, form.alignInput.z], ['input', 'box.position.x', 'input', 'box.position.z'])
192
- const align = alignOf(kept, 'box', 'plane', form.align, form.alignMm, false, form.alignInput)
193
- const again = applyFigureAuthoring(kept, { kind: 'attach', part: 'box', face: 'bottom', to: { plane: 'mounting-plane' }, ...(align ? { align } : {}), replace: true } as never)
192
+ const align = alignOf(kept, 'plane', form.align, form.alignMm, form.alignInput)
193
+ const again = applyFigureAuthoring(kept, { kind: 'attach', part: 'box', to: { plane: 'mounting-plane' }, ...(align ? { align } : {}), replace: true } as never)
194
194
  assert.ok('box.position.x' in again.designInputs && 'box.position.z' in again.designInputs, 'the fields stay')
195
195
  assert.deepEqual(JSON.stringify(again.joints), JSON.stringify(kept.joints), 'the same fastening')
196
196
  })
@@ -20,7 +20,7 @@ import { derivedHeld, derivedOf, type FigureRevision } from '../client/modeller/
20
20
  import { alignChanges, boxOf, canMirror, gridStepOf } from '../client/modeller/align.ts'
21
21
  import { jointFanOf } from '../client/modeller/joint-fan.ts'
22
22
  import { fasteningOf, measureNow, partSummaryOf, placeKeptOf } from '../client/modeller/links.ts'
23
- import { faceViewOf, myFaceFor } from '../client/modeller/fasten-geometry.ts'
23
+ 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'
@@ -96,8 +96,7 @@ function selectionQuestions(shown: FigureRevision, part: string) {
96
96
  figureRowOf(asset, part, shown.inputs)
97
97
  fasteningOf(shown, part)
98
98
  placeKeptOf(shown, part, 'plane')
99
- myFaceFor(shown, part, 'plane', 'meet')
100
- faceViewOf(shown, part, 'plane')
99
+ faceViewOf(shown, part, 'plane', 'meet')
101
100
  figureFigureFacts(shown)
102
101
  }
103
102
 
@@ -144,7 +143,7 @@ test('a value changed, read again as the next version, is two compiles at most:
144
143
  function slide(): FigureAsset {
145
144
  let a = addFigureBox(createFigureAsset('Slide'), { id: 'rail', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
146
145
  a = addFigureBox(a, { id: 'carriage', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 150, z: 0 } })
147
- a = applyFigureAuthoring(a, { kind: 'attach', part: 'carriage', face: 'bottom', to: { part: 'rail', face: 'top' } })
146
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'carriage', to: { part: 'rail', face: 'top' } })
148
147
  a = moving(a, 'carriage', { kind: 'slide', axis: 'x', id: 'travel', min: 0, max: 400 })
149
148
  return addFigureBox(a, { id: 'tag', size: { x: 50, y: 50, z: 50 }, position: { x: 300, y: 225, z: 0 } })
150
149
  }
@@ -159,7 +158,7 @@ test('fastening keeps a part where it stands against what is drawn: a target mov
159
158
  return w.open('x').then(() => {
160
159
  w.apply([{ kind: 'set-state', input: 'travel', unit: 'mm', value: 250 }])
161
160
  const before = boxOf(w.revisionNow()!, 'tag')!.centre.x
162
- w.command({ kind: 'attach', part: 'tag', face: 'bottom', to: { part: 'carriage', face: 'top' }, align: { x: { mm: placeKeptOf(shown, 'tag', { part: 'carriage', face: 'top' }).alignMm.x! } } })
161
+ w.command({ kind: 'attach', part: 'tag', to: { part: 'carriage', face: 'top' }, align: { x: { mm: placeKeptOf(shown, 'tag', { part: 'carriage', face: 'top' }).alignMm.x! } } })
163
162
  assert.equal(Math.round(boxOf(w.revisionNow()!, 'tag')!.centre.x), Math.round(before))
164
163
  })
165
164
  })
@@ -164,7 +164,7 @@ test('다각 단면에는 속 공간을 만들 수 없다고 그 자리에서
164
164
  test('붙어 있는 것이 기대는 면을 없애는 편집은 거절되고, 그 면을 사람의 말로 부른다', () => {
165
165
  let asset = applyFigureAuthoring(probe(), { kind: 'set-cavity', part: 'body', cavity: { wall: 10, start: { closed: 15 }, end: 'open' } })
166
166
  asset = addFigureBox(asset, { id: 'divider', size: { x: 10, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
167
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'divider', face: 'bottom', to: { part: 'body', face: 'inside-bottom' as never } })
167
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'divider', to: { part: 'body', face: 'inside-bottom' as never } })
168
168
  const said = refusal(() => applyFigureAuthoring(asset, { kind: 'set-cavity', part: 'body', cavity: { wall: 10, start: 'open', end: 'open' } }))
169
169
  assert.equal(said, say('figure.text.section-face-in-use', { faces: ko['figure.label.face-inside-bottom']! }))
170
170
  })