@things-factory/figure-ui 10.1.208 → 10.1.210

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 (89) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +74 -57
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +26 -120
  5. package/client/modeller/figure-size-editor.ts +196 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +36 -24
  9. package/client/modeller/motion-ends.ts +10 -6
  10. package/client/modeller/size-response.ts +95 -0
  11. package/dist-client/modeller/data-interface.d.ts +1 -1
  12. package/dist-client/modeller/data-interface.js +5 -5
  13. package/dist-client/modeller/data-interface.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +9 -1
  15. package/dist-client/modeller/editor.js +80 -58
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-geometry.js +1 -1
  18. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -4
  20. package/dist-client/modeller/figure-links.js +26 -107
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/figure-size-editor.d.ts +27 -0
  23. package/dist-client/modeller/figure-size-editor.js +221 -0
  24. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  25. package/dist-client/modeller/joint-drag.d.ts +1 -1
  26. package/dist-client/modeller/joint-drag.js +27 -22
  27. package/dist-client/modeller/joint-drag.js.map +1 -1
  28. package/dist-client/modeller/joint-fan.js +4 -3
  29. package/dist-client/modeller/joint-fan.js.map +1 -1
  30. package/dist-client/modeller/links.d.ts +9 -6
  31. package/dist-client/modeller/links.js +33 -21
  32. package/dist-client/modeller/links.js.map +1 -1
  33. package/dist-client/modeller/motion-ends.d.ts +4 -1
  34. package/dist-client/modeller/motion-ends.js +10 -6
  35. package/dist-client/modeller/motion-ends.js.map +1 -1
  36. package/dist-client/modeller/size-response.d.ts +29 -0
  37. package/dist-client/modeller/size-response.js +99 -0
  38. package/dist-client/modeller/size-response.js.map +1 -0
  39. package/dist-client/route.d.ts +1 -1
  40. package/dist-client/tsconfig.tsbuildinfo +1 -1
  41. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  42. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  43. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  44. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  45. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  46. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  47. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  48. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  49. package/docs/multipart-response-review-2026-10-24.md +77 -0
  50. package/docs/response-authoring-2026-10-24.md +46 -0
  51. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  52. package/package.json +7 -7
  53. package/test/attachment-size-contract.test.ts +47 -0
  54. package/test/browser/acceptance-page.js +1 -1
  55. package/test/browser/detail-page.js +1 -1
  56. package/test/browser/detail-tabs-page.js +1 -1
  57. package/test/browser/editing-page.js +1 -1
  58. package/test/browser/editing.check.mjs +28 -14
  59. package/test/browser/links.check.mjs +41 -22
  60. package/test/browser/modeller-page.js +1 -1
  61. package/test/browser/modeller-revision.check.mjs +21 -7
  62. package/test/browser/modeller.check.mjs +51 -39
  63. package/test/browser/multipart-response-page.js +8 -0
  64. package/test/browser/parts.check.mjs +2 -1
  65. package/test/browser/product-page.js +1 -1
  66. package/test/browser/props.check.mjs +17 -14
  67. package/test/browser/response-page.js +12 -0
  68. package/test/browser/row-preview.check.mjs +3 -3
  69. package/test/browser/screen.check.mjs +57 -13
  70. package/test/browser/serve-response.mjs +24 -0
  71. package/test/clip-ai-proposal.test.ts +1 -1
  72. package/test/clip-consent.test.ts +2 -2
  73. package/test/command-preview.test.ts +7 -7
  74. package/test/joint-drag.test.ts +6 -6
  75. package/test/joint-frames.test.ts +33 -32
  76. package/test/joints.ts +1 -6
  77. package/test/links.test.ts +21 -20
  78. package/test/multipart-response-fixture.js +17 -0
  79. package/test/multipart-response.test.ts +37 -0
  80. package/test/refusals.test.ts +2 -2
  81. package/test/resize-with-placement.test.ts +23 -1
  82. package/test/size-response.test.ts +49 -0
  83. package/test/size-source-pairing.test.ts +66 -0
  84. package/translations/en.json +44 -10
  85. package/translations/es.json +44 -10
  86. package/translations/ja.json +44 -10
  87. package/translations/ko.json +49 -15
  88. package/translations/ms.json +44 -10
  89. package/translations/zh.json +44 -10
@@ -161,8 +161,8 @@ test('a motion is named by its parts, and drawn at both ends of its range -- onl
161
161
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'gate', to: { part: 'frame', face: 'top' } })
162
162
  const j = (part: string) => a.joints!.find(k => k.body1.part === part)!
163
163
  assert.equal(motionEndsOf(revisionOf(a), 'leaf'), null, 'not moving: nothing drawn')
164
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('leaf'), type: 'revolute', axis: 'Y', lowerLimit: 0, upperLimit: 90, state: { id: 'open' } } })
165
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('gate'), type: 'prismatic', axis: 'X', lowerLimit: 0, upperLimit: 300, state: { id: 'slide' } } })
164
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('leaf'), motion: [{ type: 'revolute', axis: 'Y', lowerLimit: 0, upperLimit: 90, state: { id: 'open' } }] } })
165
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('gate'), motion: [{ type: 'prismatic', axis: 'X', lowerLimit: 0, upperLimit: 300, state: { id: 'slide' } }] } })
166
166
  /* At the version the screen holds, a design value typed since it was opened. */
167
167
  const held = revisionOf(a, { 'frame.size.x': 2400 })
168
168
  const turn = motionEndsOf(held, 'leaf')!
@@ -204,19 +204,19 @@ test('a moving part is made into a row, and a row given or relieved of a motion
204
204
  assert.deepEqual(rowAxesOf(now(), 'roller'), ['x', 'z'])
205
205
  s.command({ kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
206
206
  assert.equal(rollers(), 10)
207
- assert.equal(jointOf().type, 'revolute')
207
+ assert.equal((jointOf().motion?.[0]?.type ?? 'fixed'), 'revolute')
208
208
  /* The motion changed by the form: the row stays. */
209
209
  s.command({ kind: 'set-joint', joint: motionJoint(jointOf(), { ...(turn as object), kind: 'slide', axis: 'z', min: 0, max: 100 } as never, 'roller') })
210
- assert.deepEqual([jointOf().type, rollers()], ['prismatic', 10])
210
+ assert.deepEqual([(jointOf().motion?.[0]?.type ?? 'fixed'), rollers()], ['prismatic', 10])
211
211
  /* The motion taken away by its button: still a row. */
212
212
  s.command({ kind: 'set-joint', joint: fixedJointOf(jointOf()) })
213
- assert.deepEqual([jointOf().type, rollers()], ['fixed', 10])
213
+ assert.deepEqual([(jointOf().motion?.[0]?.type ?? 'fixed'), rollers()], ['fixed', 10])
214
214
  /* Each undone once. */
215
215
  s.undo()
216
- assert.deepEqual([jointOf().type, rollers()], ['prismatic', 10])
216
+ assert.deepEqual([(jointOf().motion?.[0]?.type ?? 'fixed'), rollers()], ['prismatic', 10])
217
217
  s.undo()
218
218
  s.undo()
219
- assert.deepEqual([jointOf().type, rollers()], ['revolute', 1])
219
+ assert.deepEqual([(jointOf().motion?.[0]?.type ?? 'fixed'), rollers()], ['revolute', 1])
220
220
  })
221
221
 
222
222
  /* Designer 2026-09-27: wider than its face is not hidden -- a fact in the relation's details, off to one side said. */
@@ -17,7 +17,7 @@ function column(joint: Partial<FigureJoint>): FigureAsset {
17
17
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'post', to: { plane: 'mounting-plane' } })
18
18
  return applyFigureAuthoring(asset, {
19
19
  kind: 'set-joint',
20
- joint: { id: 'bend', type: 'fixed', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, ...joint } as FigureJoint
20
+ joint: { id: 'bend', motion: undefined, body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, ...joint } as FigureJoint
21
21
  })
22
22
  }
23
23
 
@@ -28,7 +28,7 @@ const drawn = (asset: FigureAsset, id: string, state: Record<string, number> = {
28
28
  const turnedZ = (deg: number) => [0, 0, deg] as [number, number, number]
29
29
 
30
30
  test('돌리면 관절값 — 관절 축 둘레로 돈 만큼, 범위 안으로', () => {
31
- const asset = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
31
+ const asset = column({ motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 }] })
32
32
  const part = drawn(asset, 'link')
33
33
  assert.deepEqual(jointDragOf(asset, 'link', part as never, { rotation: turnedZ(30) }, {}), { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
34
34
  assert.deepEqual(jointDragOf(asset, 'link', part as never, { rotation: turnedZ(120) }, {}), { kind: 'value', input: 'bend', unit: 'deg', value: 90 }, '범위 끝에서 멈춘다')
@@ -38,7 +38,7 @@ test('돌리면 관절값 — 관절 축 둘레로 돈 만큼, 범위 안으로'
38
38
  })
39
39
 
40
40
  test('끝없이 도는 관절은 −180..180 으로 감는다', () => {
41
- const asset = column({ type: 'revolute', axis: 'Y' })
41
+ const asset = column({ motion: [{ type: 'revolute', axis: 'Y' }] })
42
42
  const at170 = drawn(asset, 'link', { bend: 170 })
43
43
  const drag = jointDragOf(asset, 'link', at170 as never, { rotation: [0, 190, 0] }, { bend: 170 })
44
44
  assert.equal(drag?.kind, 'value')
@@ -46,7 +46,7 @@ test('끝없이 도는 관절은 −180..180 으로 감는다', () => {
46
46
  })
47
47
 
48
48
  test('미끄러지는 관절은 축을 따라 옮긴 거리가 값', () => {
49
- const asset = column({ type: 'prismatic', axis: 'X', lowerLimit: -100, upperLimit: 100 })
49
+ const asset = column({ motion: [{ type: 'prismatic', axis: 'X', lowerLimit: -100, upperLimit: 100 }] })
50
50
  const part = drawn(asset, 'link')
51
51
  const to = [part.pose.t[0]! + 40, part.pose.t[1]!, part.pose.t[2]!] as [number, number, number]
52
52
  assert.deepEqual(jointDragOf(asset, 'link', part as never, { position: to }, {}), { kind: 'value', input: 'bend', unit: 'mm', value: 40 })
@@ -76,7 +76,7 @@ test('관계를 끊는 끌기는 거절하고 까닭을 댄다', () => {
76
76
  const edge = column({ origin: { align: { x: 'min' } } })
77
77
  const part = drawn(edge, 'link')
78
78
  assert.deepEqual(jointDragOf(edge, 'link', part as never, { position: [part.pose.t[0]! + 30, part.pose.t[1]!, part.pose.t[2]!] }, {}), { kind: 'refused', reason: 'origin-follows', joint: 'bend' }, '모서리에 맞춘 자리는 그 모서리를 따른다')
79
- const bend = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
79
+ const bend = column({ motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 }] })
80
80
  assert.deepEqual(jointDragOf(bend, 'link', drawn(bend, 'link') as never, { rotation: [25, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'bend', axis: 'Z' }, '다른 축으로 돌린 것은 적지 않는다')
81
81
  const still = column({})
82
82
  assert.deepEqual(jointDragOf(still, 'link', drawn(still, 'link') as never, { rotation: turnedZ(20) }, {}), { kind: 'refused', reason: 'fixed-turn', joint: 'bend' })
@@ -90,7 +90,7 @@ test('관절이 없는 부품은 이 길로 오지 않는다 — 제 숫자를
90
90
 
91
91
  test('크기 끌기는 관절이 아니라 부품의 치수 — 따라가는 치수는 두고 제 치수만 쓴다 (사용자 09-25: 「R 모드에서 크기가 변하지 않는다」)', async () => {
92
92
  const { gizmoChanges, gizmoReachOf } = await import('../client/modeller/gizmo.ts')
93
- let asset = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
93
+ let asset = column({ motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 }] })
94
94
  /* A square section: the link's depth follows its width. */
95
95
  asset = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'link', axis: 'z', source: { of: 'part', part: 'link', axis: 'x' } })
96
96
  const part = drawn(asset, 'link')
@@ -32,25 +32,25 @@ function framed(): FigureAsset {
32
32
  a = box(a, 'link', 100, 400, 100, 300)
33
33
  a = box(a, 'tool', 60, 120, 40)
34
34
  a = box(a, 'cap', 20, 20, 20)
35
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
36
- a = set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { pivot: 'child-face', offset: { x: 20 }, parentRotate: { x: 10 } }, axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 })
37
- a = set(a, { id: 'tool', type: 'revolute', body0: { part: 'link', at: 'origin' }, body1: { part: 'tool', at: 'origin' }, origin: { offset: { x: 120, y: SUMMED, z: -30 }, parentRotate: { x: 15, y: -30, z: 60 } }, axis: 'Z', lowerLimit: -45, upperLimit: 45 })
38
- return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap' }, origin: { offset: { y: 40 } } })
35
+ a = set(a, { id: 'base', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
36
+ a = set(a, { id: 'link', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { pivot: 'child-face', offset: { x: 20 }, parentRotate: { x: 10 } }, motion: [{ type: 'revolute', axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 }] })
37
+ a = set(a, { id: 'tool', body0: { part: 'link', at: 'origin' }, body1: { part: 'tool', at: 'origin' }, origin: { offset: { x: 120, y: SUMMED, z: -30 }, parentRotate: { x: 15, y: -30, z: 60 } }, motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -45, upperLimit: 45 }] })
38
+ return set(a, { id: 'cap', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap' }, origin: { offset: { y: 40 } } })
39
39
  }
40
40
 
41
41
  /** The old shapes only: a token axis, face ends, a gap and an edge. */
42
42
  function plain(): FigureAsset {
43
43
  let a = box(createFigureAsset('Plain'), 'base', 300, 100, 300, 50)
44
44
  a = box(a, 'link', 100, 400, 100, 300)
45
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
46
- return set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { gap: 5, align: { x: 'min' } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
45
+ a = set(a, { id: 'base', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
46
+ return set(a, { id: 'link', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { gap: 5, align: { x: 'min' } }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 90 }] })
47
47
  }
48
48
 
49
49
  const reopen = (asset: FigureAsset) => parseFigureAsset(serializeFigureAsset(asset) as unknown as string)
50
50
  const jointOf = (asset: FigureAsset, id: string) => asset.joints!.find(j => j.id === id)! as Dynamic
51
51
  /** What the frames say of a joint: what no form path may change. */
52
52
  const frameOf = (j: Dynamic, keepType = true) => ({
53
- ...(keepType ? { type: j.type, axis: j.axis } : {}),
53
+ ...(keepType ? { type: j.motion?.[0]?.type ?? 'fixed', axis: j.motion?.[0]?.axis } : {}),
54
54
  body0: j.body0,
55
55
  body1: j.body1,
56
56
  pivot: j.origin?.pivot,
@@ -80,7 +80,7 @@ function roundTrips(asset: FigureAsset, id: string) {
80
80
  const j = jointOf(asset, id)
81
81
  const part = j.body1.part
82
82
  const out: Record<string, FigureJoint> = {}
83
- if (j.type !== 'fixed') out.motion = jointOf(reopen(set(asset, motionJoint(j, motionFormOf(j), part))), id)
83
+ if (j.motion?.length) out.motion = jointOf(reopen(set(asset, motionJoint(j, motionFormOf(j), part))), id)
84
84
  const fasten = fasteningSave(asset, part)
85
85
  if (fasten) out.fastening = jointOf(reopen(applyFigureAuthoring(asset, fasten)), id)
86
86
  out.fixed = jointOf(reopen(set(asset, fixedJointOf(j))), id)
@@ -113,24 +113,25 @@ test('the axis reader: a token is its axis, a vector its unit vector, unrounded;
113
113
  test('a follower is not a turn without end: it has no limits because its leader gives its range', () => {
114
114
  let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
115
115
  a = box(a, 'b', 50, 50, 50, 125)
116
- a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
117
- a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
116
+ a = set(a, { id: 'a', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 90 }] })
117
+ a = set(a, { id: 'b', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, motion: [{ type: 'revolute', axis: 'Y', mimic: { motion: 'a', multiplier: -1 } }] })
118
118
  assert.equal(motionOf(a, 'b')?.kind, 'turn')
119
119
  assert.equal(motionFormOf(jointOf(a, 'b')).kind, 'turn')
120
120
  assert.equal(motionOf(a, 'a')?.kind, 'turn')
121
- const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y' })
121
+ const free = set(a, { id: 'a', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, motion: [{ type: 'revolute', axis: 'Y' }] })
122
122
  assert.equal(motionOf(free, 'a')?.kind, 'continuous', 'a joint of its own without limits still turns without end')
123
123
  })
124
124
 
125
125
  test('a new motion replaces the old one whole: made a follower, its own limits and control go; made still, its axis goes (ADR-0140 D6)', () => {
126
126
  let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
127
127
  a = box(a, 'b', 50, 50, 50, 125)
128
- a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
129
- a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, origin: { gap: 5 }, axis: 'Y', lowerLimit: -30, upperLimit: 30, state: { id: 'b-turn' } })
128
+ a = set(a, { id: 'a', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 90 }] })
129
+ a = set(a, { id: 'b', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, origin: { gap: 5 }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -30, upperLimit: 30, state: { id: 'b-turn' } }] })
130
130
  const follower = motionJoint(jointOf(a, 'b'), { ...motionFormOf(jointOf(a, 'b')), follow: 'a', multiplier: -1 } as Dynamic, 'b') as Dynamic
131
- assert.deepEqual(Object.keys(follower), ['id', 'body0', 'body1', 'origin', 'type', 'axis', 'mimic'])
131
+ assert.deepEqual(Object.keys(follower), ['id', 'body0', 'body1', 'origin', 'motion'])
132
+ assert.deepEqual(Object.keys(follower.motion[0]), ['type', 'axis', 'mimic'], 'its own limits and control gone')
132
133
  assert.doesNotThrow(() => set(a, follower))
133
- assert.deepEqual(Object.keys(fixedJointOf(jointOf(a, 'b'))), ['id', 'type', 'body0', 'body1', 'origin'])
134
+ assert.deepEqual(Object.keys(fixedJointOf(jointOf(a, 'b'))), ['id', 'body0', 'body1', 'origin'])
134
135
  })
135
136
 
136
137
  test('a range need not hold 0; it needs its most above its least', () => {
@@ -145,7 +146,7 @@ test('a range need not hold 0; it needs its most above its least', () => {
145
146
  const still = set(plain(), fixedJointOf(jointOf(plain(), 'link')))
146
147
  const fixed = jointOf(still, 'link')
147
148
  const lifted = set(still, motionJoint(fixed, { ...motionFormOf(fixed), kind: 'turn', min: 10, max: 90 }, 'link'))
148
- assert.equal(lifted.stateDefaults[jointOf(lifted, 'link').state.id], 10)
149
+ assert.equal(lifted.stateDefaults[jointOf(lifted, 'link').motion[0].state.id], 10)
149
150
  /* An existing control keeps its name and its stored value: a range that leaves that value out is the kernel's refusal (R1). */
150
151
  const j = jointOf(plain(), 'link')
151
152
  assert.throws(() => set(plain(), motionJoint(j, { ...motionFormOf(j), min: 10, max: 90 }, 'link')), /INPUT_RANGE/)
@@ -170,8 +171,8 @@ test('a vector axis: the form keeps it, the summary says it, saved and reopened
170
171
  assert.deepEqual(motionOf(asset, 'link')?.vector, { x: 1, y: 1, z: 0 })
171
172
  /* Changing the range leaves the axis alone. */
172
173
  const saved = reopen(set(asset, motionJoint(jointOf(asset, 'link'), { ...form, min: -30, max: 60 }, 'link')))
173
- assert.deepEqual(jointOf(saved, 'link').axis, { x: 1, y: 1, z: 0 })
174
- assert.equal(jointOf(saved, 'link').upperLimit, 60)
174
+ assert.deepEqual(jointOf(saved, 'link').motion[0].axis, { x: 1, y: 1, z: 0 })
175
+ assert.equal(jointOf(saved, 'link').motion[0].upperLimit, 60)
175
176
  })
176
177
 
177
178
  test('frames: every form path keeps the vector axis, pivot, offset (summed too), parentRotate and origin ends, saved and reopened', () => {
@@ -209,8 +210,8 @@ test('placed by an origin: said as the origin, no row offered, and the fastening
209
210
  test('a drag about a vector axis sets the value; off it, it is refused and the vector said', () => {
210
211
  let a = box(createFigureAsset('Tilt'), 'post', 100, 200, 100, 100)
211
212
  a = box(a, 'head', 60, 60, 60, 230)
212
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
213
- a = set(a, { id: 'head', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'head' }, axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 })
213
+ a = set(a, { id: 'post', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
214
+ a = set(a, { id: 'head', body0: { part: 'post', face: 'top' }, body1: { part: 'head' }, motion: [{ type: 'revolute', axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 }] })
214
215
  const at = drawn(a, 'head')
215
216
  /* 30° about (0, 0.6, 0.8), as XYZ degrees. */
216
217
  const q = [0, 0.6, 0.8].map(v => v * Math.sin(Math.PI / 12))
@@ -223,7 +224,7 @@ test('a drag about a vector axis sets the value; off it, it is refused and the v
223
224
  assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: deg }, {}), { kind: 'value', input: 'head', unit: 'deg', value: 30 })
224
225
  assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: [40, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'head', axis: '(0, 3, 4)' })
225
226
  /* A slide along it: the distance along the vector. */
226
- const slide = set(a, { ...jointOf(a, 'head'), type: 'prismatic', lowerLimit: -100, upperLimit: 100 })
227
+ const slide = set(a, { ...jointOf(a, 'head'), motion: [{ ...jointOf(a, 'head').motion[0], type: 'prismatic', lowerLimit: -100, upperLimit: 100 }] })
227
228
  const s = drawn(slide, 'head')
228
229
  assert.deepEqual(jointDragOf(slide, 'head', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 30, s.pose.t[2]! + 40] }, {}), { kind: 'value', input: 'head', unit: 'mm', value: 50 })
229
230
  })
@@ -231,8 +232,8 @@ test('a drag about a vector axis sets the value; off it, it is refused and the v
231
232
  test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', () => {
232
233
  let a = box(createFigureAsset('Rotated'), 'post', 100, 200, 100, 100)
233
234
  a = box(a, 'link', 60, 300, 60, 350)
234
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
235
- a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { parentRotate: { x: 90 } }, axis: 'Z', lowerLimit: -90, upperLimit: 90 })
235
+ a = set(a, { id: 'post', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
236
+ a = set(a, { id: 'bend', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { parentRotate: { x: 90 } }, motion: [{ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 }] })
236
237
  /* Turned 90° about X, the frame's Z is the figure's -Y: 20° about -Y is 20 on the joint. */
237
238
  const at = drawn(a, 'link', { bend: 10 })
238
239
  const before = at.pose.r
@@ -249,7 +250,7 @@ test('a rotated joint frame: a turn about its turned axis is its value, and a mo
249
250
  const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
250
251
  assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
251
252
  /* Held still, moved 30 along X: an offset across the post's top on X, not along the turned frame. */
252
- const still = set(a, { ...jointOf(a, 'bend'), type: 'fixed', axis: undefined, lowerLimit: undefined, upperLimit: undefined })
253
+ const still = set(a, { ...jointOf(a, 'bend'), motion: undefined })
253
254
  const s = drawn(still, 'link')
254
255
  const moved = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]!, s.pose.t[2]! + 30] }, {}) as { joint: Dynamic }
255
256
  assert.deepEqual(moved.joint.origin, { parentRotate: { x: 90 }, align: { z: { mm: 30 } } })
@@ -260,8 +261,8 @@ test('a rotated joint frame: a turn about its turned axis is its value, and a mo
260
261
  test('a fan stands about a vector axis, as it does about the same token', () => {
261
262
  let a = box(createFigureAsset('Fan'), 'base', 200, 100, 200, 50)
262
263
  a = box(a, 'arm', 400, 50, 50, 125)
263
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
264
- const arm = (axis: unknown) => set(a, { id: 'arm', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'arm' }, axis, lowerLimit: -45, upperLimit: 90 })
264
+ a = set(a, { id: 'base', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
265
+ const arm = (axis: unknown) => set(a, { id: 'arm', body0: { part: 'base', face: 'top' }, body1: { part: 'arm' }, motion: [{ type: 'revolute', axis, lowerLimit: -45, upperLimit: 90 }] })
265
266
  const token = jointFanOf(revisionOf(arm('Y')), 'arm') as never as { pose: { t: number[]; r: number[][] } }
266
267
  const vector = jointFanOf(revisionOf(arm({ x: 0, y: 2, z: 0 })), 'arm') as never as { pose: { t: number[]; r: number[][] } }
267
268
  assert.ok(token && vector, 'a fan for both')
@@ -280,8 +281,8 @@ test('editing a motion keeps the control named after the joint when it has no st
280
281
  let a = createFigureAsset('Hinge')
281
282
  a = addFigureBox(a, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
282
283
  a = addFigureBox(a, { id: 'lid', size: { x: 200, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
283
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } } })
284
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'lid' }, axis: 'X', lowerLimit: -90, upperLimit: 90 } })
284
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } } })
285
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', body0: { part: 'base', face: 'top' }, body1: { part: 'lid' }, motion: [{ type: 'revolute', axis: 'X', lowerLimit: -90, upperLimit: 90 }] } })
285
286
  const lid = a.joints!.find(j => j.id === 'lid')!
286
287
  const form = motionFormOf(lid)
287
288
  assert.equal(form.id, 'lid', 'the form starts from the input the joint is driven by')
@@ -292,8 +293,8 @@ test('editing a motion keeps the control named after the joint when it has no st
292
293
  test("a child posed on two axes (childRotate, Rz·Ry·Rx): a turn about the joint's axis is its value, whatever its pose", () => {
293
294
  let a = box(createFigureAsset('Leaning'), 'post', 100, 200, 100, 100)
294
295
  a = box(a, 'link', 60, 300, 60, 350)
295
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
296
- a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 30, z: 40 } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
296
+ a = set(a, { id: 'post', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
297
+ a = set(a, { id: 'bend', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 30, z: 40 } }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 90 }] })
297
298
  const at = drawn(a, 'link', { bend: 10 })
298
299
  const after = drawn(a, 'link', { bend: 35 }).pose.r
299
300
  const ry = Math.asin(Math.max(-1, Math.min(1, after[0]![2]!)))
@@ -306,9 +307,9 @@ test("a child posed on two axes (childRotate, Rz·Ry·Rx): a turn about the join
306
307
  test("a child turned on its own end (childRotate): a turn about the joint's axis is its value -- the child's own turn is not the joint's", () => {
307
308
  let a = box(createFigureAsset('Laid'), 'post', 100, 200, 100, 100)
308
309
  a = box(a, 'link', 60, 300, 60, 350)
309
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
310
+ a = set(a, { id: 'post', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
310
311
  /* The link laid on its side on the post's top (a quarter turn about X), turning about the figure's Y. */
311
- a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 90 } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
312
+ a = set(a, { id: 'bend', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 90 } }, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 90 }] })
312
313
  const at = drawn(a, 'link', { bend: 10 })
313
314
  const before = at.pose.r
314
315
  const c = Math.cos((20 * Math.PI) / 180),
package/test/joints.ts CHANGED
@@ -14,7 +14,6 @@ export function moving(
14
14
  const at = (axis: 'x' | 'z') => ({ mm: asset.designInputs[`${part}.position.${axis}`] ?? 0 })
15
15
  const above: FigureJoint = asset.joints?.find(j => j.body1.part === part) ?? {
16
16
  id: part,
17
- type: 'fixed',
18
17
  body0: { plane: 'mounting-plane' },
19
18
  body1: { part },
20
19
  origin: { align: { x: at('x'), z: at('z') } }
@@ -23,11 +22,7 @@ export function moving(
23
22
  kind: 'set-joint',
24
23
  joint: {
25
24
  ...above,
26
- type: spec.kind === 'slide' ? 'prismatic' : 'revolute',
27
- axis: spec.axis.toUpperCase() as 'X' | 'Y' | 'Z',
28
- lowerLimit: spec.min,
29
- upperLimit: spec.max,
30
- state: { id: spec.id, ...(spec.start !== undefined ? { start: spec.start } : {}) }
25
+ motion: [{ type: spec.kind === 'slide' ? 'prismatic' : 'revolute', axis: spec.axis.toUpperCase() as 'X' | 'Y' | 'Z', lowerLimit: spec.min, upperLimit: spec.max, state: { id: spec.id, ...(spec.start !== undefined ? { start: spec.start } : {}) } }]
31
26
  }
32
27
  })
33
28
  }
@@ -67,7 +67,7 @@ test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는
67
67
  const turning = moving(asset, 'block', { kind: 'turn', axis: 'y', id: 'swing', min: -90, max: 90, start: 0 })
68
68
  assert.deepEqual([motionOf(turning, 'block')!.kind, motionOf(turning, 'block')!.axis], ['turn', 'y'])
69
69
  const joint = sliding.joints!.find(j => j.body1.part === 'block')!
70
- const still = applyFigureAuthoring(sliding, { kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1 } })
70
+ const still = applyFigureAuthoring(sliding, { kind: 'set-joint', joint: { id: joint.id, body0: joint.body0, body1: joint.body1 } })
71
71
  assert.equal(motionOf(still, 'block'), undefined, 'a fixed joint does not move')
72
72
  })
73
73
 
@@ -256,7 +256,7 @@ test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한
256
256
  asset = applyFigureAuthoring(asset, { kind: 'scale-uniformly' })
257
257
  assert.deepEqual(proportionToFollow(asset), { count: 3 + 3 + 1, kept: [] }, '치수 여섯과 자리 하나')
258
258
  const lamp = asset.joints!.find(j => j.id === 'lamp')!
259
- asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...lamp, type: 'prismatic', axis: 'Y', lowerLimit: 0, upperLimit: 50, state: { id: 'rise' } } })
259
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...lamp, motion: [{ type: 'prismatic', axis: 'Y', lowerLimit: 0, upperLimit: 50, state: { id: 'rise' } }] } })
260
260
  asset = applyFigureAuthoring(asset, { kind: 'follow-size-in-proportion' })
261
261
  assert.deepEqual(proportionToFollow(asset), { count: 0, kept: ['lamp.motion'] }, '다 따르고, mm 로 움직이는 것만 남는다')
262
262
  assert.equal(Math.round(valueNow(revisionOf(asset), 'lamp.dimension.x.value')! * 1e6) / 1e6, 60, '따르게 해도 지금 크기는 그대로')
@@ -359,9 +359,9 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
359
359
  assert.deepEqual(leadersFor(asset, 'left', 'slide'), [], '제 관절은 고르지 않는다')
360
360
 
361
361
  const follower = motionJoint(jointOf('right'), { ...form, id: 'ignored', follow: 'left', multiplier: -1 }, 'right')
362
- assert.deepEqual(follower.mimic, { joint: 'left', multiplier: -1 })
363
- assert.equal(follower.state, undefined, '따라가는 관절은 제 조작값이 없다')
364
- assert.equal(follower.lowerLimit, undefined)
362
+ assert.deepEqual(follower.motion![0]!.mimic, { motion: 'left', multiplier: -1 })
363
+ assert.equal(follower.motion![0]!.state, undefined, '따라가는 관절은 제 조작값이 없다')
364
+ assert.equal(follower.motion![0]!.lowerLimit, undefined)
365
365
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: follower })
366
366
  assert.deepEqual(leadersFor(asset, 'wrist', 'slide').map(j => j.id), ['left'], '따라가는 관절은 앞장서지 않는다')
367
367
 
@@ -477,8 +477,8 @@ test('미끄러짐의 이동 거리가 부품 길이를 따른다 — 레일이
477
477
  const joint = asset.joints!.find(j => j.body1.part === 'bridge')!
478
478
  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 }
479
479
  const record = motionJoint(joint, form, 'bridge')
480
- assert.deepEqual(record.travel, { source: { of: 'part', part: 'rail', axis: 'x' }, plus: -160 })
481
- assert.equal(record.lowerLimit, undefined, '거리는 레일이 정한다 — mm 범위는 없다')
480
+ assert.deepEqual(record.motion![0]!.travel, { source: { of: 'part', part: 'rail', axis: 'x' }, plus: -160 })
481
+ assert.equal(record.motion![0]!.lowerLimit, undefined, '거리는 레일이 정한다 — mm 범위는 없다')
482
482
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: record })
483
483
  assert.deepEqual(motionOf(asset, 'bridge')?.travel, { part: 'rail', axis: 'x', plus: -160 }, '화면은 무엇을 따르는지 읽는다')
484
484
  const end = (railLength: number) => {
@@ -517,7 +517,7 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
517
517
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
518
518
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
519
519
  const j = asset.joints!.find(o => o.id === 'leaf')!
520
- asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
520
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } }] } })
521
521
  assert.deepEqual(instanceSizeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
522
522
  })
523
523
 
@@ -534,20 +534,21 @@ test('움직임은 그 자리에서 고친다 — 폼에 지금 관절을 채우
534
534
  const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
535
535
  const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b' } }
536
536
  const joints = [
537
- { ...base, type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } },
538
- { ...base, type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } },
539
- { ...base, type: 'revolute' as const, axis: 'Y' as const, state: { id: 'spin' } },
540
- { ...base, type: 'prismatic' as const, axis: 'X' as const, mimic: { joint: 'k', multiplier: -1 } },
541
- { ...base, type: 'prismatic' as const, axis: 'X' as const, travel: { source: { of: 'part' as const, part: 'rail', axis: 'x' as const }, plus: -160 }, state: { id: 'x' } }
537
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } }] },
538
+ { ...base, motion: [{ type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } }] },
539
+ { ...base, motion: [{ type: 'revolute' as const, axis: 'Y' as const, state: { id: 'spin' } }] },
540
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'X' as const, mimic: { motion: 'k', multiplier: -1 } }] },
541
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'X' as const, travel: { source: { of: 'part' as const, part: 'rail', axis: 'x' as const }, plus: -160 }, state: { id: 'x' } }] }
542
542
  ]
543
543
  for (const joint of joints) {
544
544
  const form = motionFormOf(joint)
545
- assert.deepEqual(motionJoint(joint, form, 'b'), joint, `${joint.type} ${JSON.stringify(joint.mimic ?? joint.travel ?? joint.lowerLimit ?? 'endless')}`)
545
+ const a = joint.motion[0]!
546
+ assert.deepEqual(motionJoint(joint, form, 'b'), joint, `${a.type} ${JSON.stringify(a.mimic ?? a.travel ?? a.lowerLimit ?? 'endless')}`)
546
547
  }
547
548
  /* A leader's stroke made to follow the base's length, where it is. */
548
549
  const changed = motionJoint(joints[0]!, { ...motionFormOf(joints[0]!), travel: 'base:z', travelPlus: -400 }, 'b')
549
- assert.deepEqual(changed.travel, { source: { of: 'part', part: 'base', axis: 'z' }, plus: -400 })
550
- assert.equal(changed.state?.id, 'extend', '같은 조작값 이름')
550
+ assert.deepEqual(changed.motion![0]!.travel, { source: { of: 'part', part: 'base', axis: 'z' }, plus: -400 })
551
+ assert.equal(changed.motion![0]!.state?.id, 'extend', '같은 조작값 이름')
551
552
  })
552
553
 
553
554
  test('바닥에 선 부품도 무엇에 붙어 있는지 읽는다 — 그래야 옮겨 붙일 수 있다 (문설주, 2026-09-25)', async () => {
@@ -693,12 +694,12 @@ test('a follower of several leaders and a stroke that is a sum of lengths are wr
693
694
  assert.equal(form.follow, '', 'no single leader to pick')
694
695
  assert.deepEqual(form.followSum, [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }])
695
696
  const saved = motionJoint(wrist, { ...form, axis: form.axis }, 'wrist')
696
- assert.deepEqual(saved.mimic, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }] }, 'saving the form keeps the follower a follower')
697
- assert.equal(saved.state, undefined, 'and gives it no control of its own')
697
+ assert.deepEqual(saved.motion![0]!.mimic, { sum: [{ motion: 'j2', multiplier: -1 }, { motion: 'j3', multiplier: -1 }] }, 'saving the form keeps the follower a follower')
698
+ assert.equal(saved.motion![0]!.state, undefined, 'and gives it no control of its own')
698
699
  assert.deepEqual(motionOf(asset, 'wrist')?.follows, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }], offset: 0 })
699
700
 
700
- const reach = { ...wrist, id: 'reach', type: 'prismatic' as const, mimic: undefined, axis: 'X' as const, travel: { sum: [{ from: { of: 'part' as const, part: 'base', axis: 'x' as const } }, { from: { of: 'part' as const, part: 'turret', axis: 'x' as const }, times: 0.5 }] } }
701
+ const reach = { ...wrist, id: 'reach', motion: [{ ...(wrist).motion![0]!, type: 'prismatic' as const, mimic: undefined, axis: 'X' as const, travel: { sum: [{ from: { of: 'part' as const, part: 'base', axis: 'x' as const } }, { from: { of: 'part' as const, part: 'turret', axis: 'x' as const }, times: 0.5 }] } }] }
701
702
  const slide = motionFormOf(reach)
702
703
  assert.equal(slide.travel, '')
703
- assert.deepEqual(motionJoint(reach, slide, 'wrist').travel, reach.travel, 'saving keeps the summed stroke')
704
+ assert.deepEqual(motionJoint(reach, slide, 'wrist').motion![0]!.travel, reach.motion[0]!.travel, 'saving keeps the summed stroke')
704
705
  })
@@ -0,0 +1,17 @@
1
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
2
+
3
+ // A relation audit fixture, not a replacement for testing the authoring workflow.
4
+ export function multipartResponseFixture() {
5
+ let a = addFigureBox(createFigureAsset('부품 관계 점검'), {
6
+ id: 'tabletop', size: { x: 1500, y: 80, z: 600 }, position: { x: 0, y: 740, z: 0 }
7
+ })
8
+ a = applyFigureAuthoring(a, { kind: 'follow-size', part: 'tabletop', axes: ['x'] })
9
+ a = addFigureBox(a, { id: 'brace', size: { x: 1300, y: 60, z: 40 }, position: { x: 0, y: 0, z: 0 } })
10
+ a = applyFigureAuthoring(a, { kind: 'link-dimension', part: 'brace', axis: 'x', source: { of: 'part', part: 'tabletop', axis: 'x' }, plus: -200 })
11
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'brace', to: { part: 'tabletop', face: 'bottom' }, align: { z: 'min' } })
12
+ for (const [id, x] of [['legLeft', 'min'], ['legRight', 'max']]) {
13
+ a = addFigureBox(a, { id, size: { x: 80, y: 700, z: 80 }, position: { x: 0, y: 0, z: 0 } })
14
+ a = applyFigureAuthoring(a, { kind: 'attach', part: id, to: { part: 'tabletop', face: 'bottom' }, align: { x, z: 'max' } })
15
+ }
16
+ return a
17
+ }
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { compileFigureAsset, applyFigureAuthoring, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { multipartResponseFixture } from './multipart-response-fixture.js'
5
+ const part = (a: ReturnType<typeof multipartResponseFixture>, width: number, id: string) =>
6
+ compileFigureAsset({ ...a, designInputs: { ...a.designInputs, 'size.x': width } }).evaluate().geometry.find(g => g.id === id)!
7
+
8
+ test('linked brace keeps a 200 mm difference while the tabletop responds to figure width', () => {
9
+ const a = multipartResponseFixture()
10
+ assert.deepEqual(figureFollowOf(a, 'brace', 'x'), { of: 'part', part: 'tabletop', axis: 'x', plus: -200 })
11
+ for (const width of [1500, 2000, 3000]) {
12
+ assert.equal(part(a, width, 'tabletop').shape.dimensions['size.x'], width)
13
+ assert.equal(part(a, width, 'brace').shape.dimensions['size.x'], width - 200)
14
+ }
15
+ })
16
+
17
+ test('fixed leg dimensions do not imply fixed position: attachment follows the tabletop edge', () => {
18
+ const a = multipartResponseFixture()
19
+ for (const id of ['legLeft', 'legRight']) {
20
+ for (const axis of ['x', 'y', 'z'] as const) assert.equal(figureFollowOf(a, id, axis).of, 'own')
21
+ assert.deepEqual(part(a, 1500, id).shape, part(a, 3000, id).shape)
22
+ const sign = id === 'legLeft' ? -1 : 1
23
+ assert.equal(part(a, 1500, id).pose.t[0], sign * 710)
24
+ assert.equal(part(a, 3000, id).pose.t[0], sign * 1460)
25
+ assert.equal(part(a, 3000, id).pose.t[1], 350)
26
+ }
27
+ })
28
+
29
+ test('a fixed difference and a proportional relation are not interchangeable', () => {
30
+ const a = multipartResponseFixture()
31
+ // Characterize the command currently invoked by the bulk button, not an endorsement of that UI.
32
+ const proportional = applyFigureAuthoring(a, { kind: 'follow-size', part: 'brace' })
33
+ assert.equal(figureFollowOf(proportional, 'brace', 'x').of, 'instance')
34
+ assert.equal(part(a, 3000, 'brace').shape.dimensions['size.x'], 2800)
35
+ assert.ok(Math.abs(part(proportional, 3000, 'brace').shape.dimensions['size.x']! - 2600) < 0.01)
36
+ assert.deepEqual(proportional.joints, a.joints, 'attachment remains while dimension reference is replaced')
37
+ })
@@ -99,9 +99,9 @@ test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그
99
99
  a = act(a, { kind: 'attach', part: 'top', to: { part: 'leg', face: 'top' } })
100
100
  a = act(a, { kind: 'attach', part: 'lamp', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
101
101
  a = act(a, { kind: 'attach', part: 'shade', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
102
- 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 } })
102
+ const turn = (asset: typeof a, part: string, extra: object) => act(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, motion: [{ type: 'revolute', axis: 'Y', ...extra }] } })
103
103
  a = turn(a, 'lamp', { state: { id: 'lamp-turn' } })
104
- a = turn(a, 'shade', { mimic: { joint: 'lamp', multiplier: 1 } })
104
+ a = turn(a, 'shade', { mimic: { motion: 'lamp', multiplier: 1 } })
105
105
 
106
106
  const top = fm.removePart(a, { kind: 'remove-part', target: 'top' })
107
107
  const told = removalNoticeOf(top.notice, keyed)!
@@ -156,7 +156,7 @@ test('a part fastened at any angle is sized by the gizmo too: its fastening keep
156
156
  const { gizmoReachOf } = await import('../client/modeller/gizmo.js')
157
157
  let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
158
158
  a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
159
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', type: 'fixed', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
159
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
160
160
  assert.ok(gizmoReachOf(a, 'blade').scale, 'offered')
161
161
  const size = [{ kind: 'set-design', input: 'blade.size.x', unit: 'mm', value: 400 }] as never
162
162
  assert.deepEqual(figureSizeEditOf(a, 'blade', size, { keep: 'opposite', axis: 'x', side: 1 }), { changes: size, kept: 'relation' })
@@ -193,3 +193,25 @@ test('the fastening form keeps a place that follows a position field, and fasten
193
193
  assert.ok('box.position.x' in again.designInputs && 'box.position.z' in again.designInputs, 'the fields stay')
194
194
  assert.deepEqual(JSON.stringify(again.joints), JSON.stringify(kept.joints), 'the same fastening')
195
195
  })
196
+
197
+ test('fresh frame: width follows without changing height, depth or pose; one undo restores the undeclared figure', async () => {
198
+ const initial = addFigureBox(createFigureAsset('Frame'), { id: 'frame', size: { x: 1500, y: 80, z: 600 }, position: { x: 0, y: 50, z: 0 } })
199
+ const source = serializeFigureAsset(initial)
200
+ const session = createFigureProductSession({ load: async () => ({ id: 'frame', source }), save: async q => ({ id: 'frame', source: q.source }) })
201
+ await session.open('frame')
202
+ const before = session.source()
203
+ session.command({ kind: 'follow-size', part: 'frame', axes: ['x'] })
204
+ const after = session.source()
205
+ const asset = parseFigureAsset(after)
206
+ assert.equal(figureFollowOf(asset, 'frame', 'x').of, 'instance')
207
+ for (const axis of ['y', 'z'] as const) assert.equal(figureFollowOf(asset, 'frame', axis).of, 'own')
208
+ const geometry = (a: typeof asset) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'frame')!
209
+ assert.deepEqual(geometry(asset), geometry(initial), 'enabling response preserves the current geometry and pose')
210
+ const enlarged = geometry({ ...asset, designInputs: { ...asset.designInputs, 'size.x': asset.designInputs['size.x']! * 2, 'size.y': asset.designInputs['size.y']! * 2, 'size.z': asset.designInputs['size.z']! * 2 } })
211
+ const expected = geometry(addFigureBox(createFigureAsset('Frame'), { id: 'frame', size: { x: 3000, y: 80, z: 600 }, position: { x: 0, y: 50, z: 0 } }))
212
+ assert.deepEqual(enlarged, expected, 'only width doubles even when every figure dimension doubles')
213
+ session.undo()
214
+ assert.equal(session.source(), before, 'one undo also removes the automatically declared figure size')
215
+ session.redo()
216
+ assert.equal(session.source(), after)
217
+ })
@@ -0,0 +1,49 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { applyFigureAuthoring, compileFigureAsset, figureRowOf, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { sizeRelationSteps, sizeRelationOf, sizeInputOf } from '../client/modeller/size-response.ts'
5
+ import type { FigureRevision } from '../client/modeller/revision.ts'
6
+ import { multipartResponseFixture } from './multipart-response-fixture.js'
7
+ const revision = (asset: ReturnType<typeof multipartResponseFixture>) => ({ asset, inputs: asset.designInputs } as FigureRevision)
8
+ const width = (a: ReturnType<typeof multipartResponseFixture>, id: string) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === id)!.shape.dimensions['size.x']
9
+
10
+ test('switching difference to ratio preserves current size, changes future response, and leaves attachment intact', () => {
11
+ const a = multipartResponseFixture()
12
+ assert.equal(sizeRelationOf(revision(a), 'brace', 'x').mode, 'difference')
13
+ const b = sizeRelationSteps(revision(a), 'brace', 'x', {of:'part',part:'tabletop',axis:'x'}, 'ratio').reduce(applyFigureAuthoring, a)
14
+ assert.equal(width(b,'brace'),1300)
15
+ assert.deepEqual(b.joints,a.joints)
16
+ assert.equal(sizeRelationOf(revision(b),'brace','x').mode,'ratio')
17
+ assert.equal(width({...b,designInputs:{...b.designInputs,'size.x':3000}},'brace'),2600)
18
+ })
19
+ test('own X can keep a difference from another part Y; switching source preserves its current size', () => {
20
+ const a = multipartResponseFixture()
21
+ const b = sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'tabletop',axis:'y'},'difference').reduce(applyFigureAuthoring,a)
22
+ assert.equal(width(b,'brace'),1300)
23
+ assert.deepEqual(figureFollowOf(b,'brace','x'),{of:'part',part:'tabletop',axis:'y',plus:1220})
24
+ assert.deepEqual(b.joints,a.joints)
25
+ })
26
+ test('editing a linked size updates its difference without changing the reference', () => {
27
+ const a=multipartResponseFixture()
28
+ const b=sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'tabletop',axis:'x'},'difference',1250.25).reduce(applyFigureAuthoring,a)
29
+ assert.equal(width(b,'brace'),1250.25)
30
+ assert.equal(width({...b,designInputs:{...b.designInputs,'size.x':3000}},'brace'),2750.25)
31
+ assert.equal(sizeInputOf(a,'brace','x'),undefined)
32
+ assert.ok(sizeInputOf(a,'legLeft','x'))
33
+ assert.throws(()=>sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'brace',axis:'x'},'ratio'),/SIZE_SOURCE/)
34
+ })
35
+
36
+ test('repeat settings can change count, direction and mode without losing the attachment or size relation', () => {
37
+ let a=applyFigureAuthoring(multipartResponseFixture(), {kind:'make-repeat',part:'legLeft',axis:'x',pitch:200})
38
+ const initial=a.joints!.find(j=>j.body1.part==='legLeft')!
39
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...initial,repeat:{...initial.repeat!,count:5}}})
40
+ assert.equal(figureRowOf(a,'legLeft')!.count,5)
41
+ const current=a.joints!.find(j=>j.body1.part==='legLeft')!
42
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...current,repeat:{...current.repeat!,axis:'Z',count:3}}})
43
+ assert.equal(figureRowOf(a,'legLeft')!.count,3)
44
+ const last=a.joints!.find(j=>j.body1.part==='legLeft')!
45
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...last,repeat:{...last.repeat!,count:undefined,pitch:200}}})
46
+ assert.equal(a.joints!.find(j=>j.body1.part==='legLeft')!.repeat!.count,undefined)
47
+ assert.deepEqual(a.joints!.find(j=>j.body1.part==='legLeft')!.body0,initial.body0)
48
+ assert.deepEqual(figureFollowOf(a,'brace','x'),{of:'part',part:'tabletop',axis:'x',plus:-200})
49
+ })