@things-factory/figure-ui 10.1.208 → 10.1.211

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 (92) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +97 -88
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +87 -157
  5. package/client/modeller/figure-size-editor.ts +233 -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 +87 -36
  9. package/client/modeller/live.ts +33 -0
  10. package/client/modeller/motion-ends.ts +10 -6
  11. package/client/modeller/size-response.ts +95 -0
  12. package/dist-client/modeller/data-interface.d.ts +1 -1
  13. package/dist-client/modeller/data-interface.js +5 -5
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -1
  16. package/dist-client/modeller/editor.js +103 -89
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/fasten-geometry.js +1 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-links.d.ts +3 -4
  21. package/dist-client/modeller/figure-links.js +91 -145
  22. package/dist-client/modeller/figure-links.js.map +1 -1
  23. package/dist-client/modeller/figure-size-editor.d.ts +29 -0
  24. package/dist-client/modeller/figure-size-editor.js +262 -0
  25. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  26. package/dist-client/modeller/joint-drag.d.ts +1 -1
  27. package/dist-client/modeller/joint-drag.js +27 -22
  28. package/dist-client/modeller/joint-drag.js.map +1 -1
  29. package/dist-client/modeller/joint-fan.js +4 -3
  30. package/dist-client/modeller/joint-fan.js.map +1 -1
  31. package/dist-client/modeller/links.d.ts +21 -9
  32. package/dist-client/modeller/links.js +85 -35
  33. package/dist-client/modeller/links.js.map +1 -1
  34. package/dist-client/modeller/live.d.ts +14 -0
  35. package/dist-client/modeller/live.js +31 -0
  36. package/dist-client/modeller/live.js.map +1 -1
  37. package/dist-client/modeller/motion-ends.d.ts +4 -1
  38. package/dist-client/modeller/motion-ends.js +10 -6
  39. package/dist-client/modeller/motion-ends.js.map +1 -1
  40. package/dist-client/modeller/size-response.d.ts +29 -0
  41. package/dist-client/modeller/size-response.js +99 -0
  42. package/dist-client/modeller/size-response.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  45. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  46. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  47. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  48. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  49. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  50. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  51. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  52. package/docs/multipart-response-review-2026-10-24.md +77 -0
  53. package/docs/response-authoring-2026-10-24.md +46 -0
  54. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  55. package/package.json +7 -7
  56. package/test/attachment-size-contract.test.ts +47 -0
  57. package/test/browser/acceptance-page.js +1 -1
  58. package/test/browser/detail-page.js +1 -1
  59. package/test/browser/detail-tabs-page.js +1 -1
  60. package/test/browser/editing-page.js +1 -1
  61. package/test/browser/editing.check.mjs +66 -14
  62. package/test/browser/links.check.mjs +41 -22
  63. package/test/browser/modeller-page.js +1 -1
  64. package/test/browser/modeller-revision.check.mjs +21 -7
  65. package/test/browser/modeller.check.mjs +106 -39
  66. package/test/browser/multipart-response-page.js +8 -0
  67. package/test/browser/parts.check.mjs +2 -1
  68. package/test/browser/product-page.js +1 -1
  69. package/test/browser/props.check.mjs +17 -14
  70. package/test/browser/response-page.js +12 -0
  71. package/test/browser/row-preview.check.mjs +3 -3
  72. package/test/browser/screen.check.mjs +57 -13
  73. package/test/browser/serve-response.mjs +24 -0
  74. package/test/clip-ai-proposal.test.ts +1 -1
  75. package/test/clip-consent.test.ts +2 -2
  76. package/test/command-preview.test.ts +7 -7
  77. package/test/joint-drag.test.ts +6 -6
  78. package/test/joint-frames.test.ts +33 -32
  79. package/test/joints.ts +1 -6
  80. package/test/links.test.ts +60 -20
  81. package/test/multipart-response-fixture.js +17 -0
  82. package/test/multipart-response.test.ts +37 -0
  83. package/test/refusals.test.ts +2 -2
  84. package/test/resize-with-placement.test.ts +23 -1
  85. package/test/size-response.test.ts +49 -0
  86. package/test/size-source-pairing.test.ts +66 -0
  87. package/translations/en.json +47 -10
  88. package/translations/es.json +47 -10
  89. package/translations/ja.json +47 -10
  90. package/translations/ko.json +52 -15
  91. package/translations/ms.json +47 -10
  92. package/translations/zh.json +47 -10
@@ -82,6 +82,8 @@ const tabOn = () => read(() => screenCheck.editor().shadowRoot.querySelector('bu
82
82
  const pressBar = action => press(action, a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`), action)
83
83
  const pressTab = name => press(name, name => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="${name}"]`), name)
84
84
  const TAB = name => `figure.label.tab-short-${name}`
85
+ /* 「반응형」 while a part is chosen: named by what it holds for the part, its size and arrangement (2026-10-24). */
86
+ const ARRANGEMENT = 'figure.label.tab-arrangement'
85
87
  /*
86
88
  The view settings or the releases, opened from the bar over the work (they were tabs once); left open if open. The
87
89
  releases open from 「발행」, the bar's one way to them (user 2026-10-18).
@@ -226,7 +228,9 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
226
228
  assert.equal(await saveOn(), false, 'saved: the save button is dimmed')
227
229
 
228
230
  await pressRow('box')
229
- const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.y'))
231
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
232
+ await pressTab(ARRANGEMENT)
233
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box Y figure.label.size-measure'))
230
234
  await height.click({ clickCount: 3 })
231
235
  await page().keyboard.type('600')
232
236
  await page().keyboard.press('Enter')
@@ -240,6 +244,7 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
240
244
  await until('redone', () => probe.value('box.size.y') === 600)
241
245
  await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
242
246
  await until('saved: dimmed again', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]').disabled)
247
+ await pressTab(TAB('properties'))
243
248
 
244
249
  const width = () => read(() => Math.round(screenCheck.deep('figure-canvas')[0].getBoundingClientRect().width))
245
250
  const was = await width()
@@ -316,7 +321,7 @@ describe('the editor is drawn in the shell\'s tokens (2026-09-27)', () => {
316
321
  describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
317
322
  before(() => mount('arm'))
318
323
 
319
- test('properties hold the part\'s size and place; 「반응형」 how it answers a size; capability its places; motion its motion and controls -- each under the part\'s head', async () => {
324
+ test('properties hold the part\'s place; 「반응형」 its size and how it answers one; capability its places; motion its motion and controls -- each under the part\'s head', async () => {
320
325
  await pressRow('upper-arm')
321
326
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
322
327
  const holds = () =>
@@ -334,10 +339,11 @@ describe('a part chosen: each tab in its place, the figure-wide tabs give way, t
334
339
  await pressTab(TAB('properties'))
335
340
  let now = await holds()
336
341
  assert.ok(now.subject, 'the part\'s head')
337
- assert.ok(now.links.includes('figure.label.group-size') && now.links.includes('figure.label.group-place'), `the shape's links: ${now.links}`)
342
+ /* A box's size is the 「배치」 tab's, in its size editor (2026-10-24): the properties keep its place. */
343
+ assert.ok(now.links.includes('figure.label.group-place') && !now.links.includes('figure.label.group-size'), `the shape's links: ${now.links}`)
338
344
  assert.ok(now.material, 'its material')
339
- /* How it answers a size has a tab of its own (user 2026-10-17, 「반응형」). */
340
- await pressTab(TAB('response'))
345
+ /* How it answers a size has a tab of its own (user 2026-10-17, 「반응형」), named 「배치」 while a part is chosen. */
346
+ await pressTab(ARRANGEMENT)
341
347
  now = await holds()
342
348
  assert.ok(now.subject, 'the part\'s head')
343
349
  assert.ok(now.links.includes('figure.label.group-size'), `the response's links: ${now.links}`)
@@ -726,6 +732,8 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
726
732
  test('a width typed ten times larger fits the view again; a little wider leaves the camera where it is', async () => {
727
733
  await mount('box')
728
734
  await pressRow('box')
735
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
736
+ await pressTab(ARRANGEMENT)
729
737
  await read(() => {
730
738
  const canvas = screenCheck.deep('figure-canvas')[0]
731
739
  const fit = canvas.fit.bind(canvas)
@@ -733,7 +741,7 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
733
741
  canvas.fit = () => (window.fits++, fit())
734
742
  })
735
743
  const typeWidth = async mm => {
736
- const width = await element('the width field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.x'))
744
+ const width = await element('the width field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box X figure.label.size-measure'))
737
745
  await width.click({ clickCount: 3 })
738
746
  await page().keyboard.type(String(mm))
739
747
  await page().keyboard.press('Enter')
@@ -745,6 +753,7 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
745
753
  assert.equal(await read(() => window.fits), 0, 'a little wider: the camera stays')
746
754
  await typeWidth(4000)
747
755
  await until('the view fitted to it', () => window.fits >= 1)
756
+ await pressTab(TAB('properties'))
748
757
  await boardsUp()
749
758
  })
750
759
  })
@@ -796,16 +805,25 @@ describe('a part\'s size row: a rule\'s numbers only where a rule sets every sid
796
805
  assert.equal(await sizeRow(), null)
797
806
  })
798
807
 
808
+ /*
809
+ A box's size is the 「배치」 tab's size editor (2026-10-24), one row a side: made to follow on every side, each row
810
+ shows the size the rule gives it, and the properties draw no size row.
811
+ */
799
812
  test('a box made to follow the figure\'s size on every side shows the size the rule gives it', async () => {
800
813
  await mount('box')
801
814
  await pressRow('box')
802
815
  await until('box chosen', () => screenCheck.editor().selected === 'box')
803
- /* Following the figure's size is the 「반응형」 tab's; the size row it gives is the properties'. */
804
- await pressTab(TAB('response'))
805
- await press('follow the figure\'s size', () => screenCheck.deep('button[follow-size]').find(b => b.checkVisibility()))
816
+ await pressTab(ARRANGEMENT)
817
+ for (const axis of ['X', 'Y', 'Z']) {
818
+ const before = await revision()
819
+ await press(`${axis} made to follow`, axis => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === `box ${axis} figure.label.size-follows`), axis)
820
+ await until(`${axis} following`, before => screenCheck.editor().session.read().revision > before, before)
821
+ }
806
822
  await until('following', () => ['x', 'y', 'z'].every(axis => !screenCheck.editor().session.read().fields.some(f => f.id === `box.size.${axis}`)))
823
+ const measures = await read(() => ['X', 'Y', 'Z'].map(axis => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === `box ${axis} figure.label.size-measure`)?.value))
824
+ assert.deepEqual(measures, ['400', '400', '400'], 'each side the size the rule gives it')
807
825
  await pressTab(TAB('properties'))
808
- assert.deepEqual(await sizeRow(), { held: 3 }, 'three sides, each held by the rule')
826
+ assert.equal(await sizeRow(), null, 'no size row on the properties')
809
827
  })
810
828
  })
811
829
 
@@ -829,7 +847,20 @@ describe('the side panel is found as it was left, per part', () => {
829
847
  }
830
848
  before(() => mount('arm'))
831
849
 
850
+ /*
851
+ The part's properties fit a tall window since its size moved to 「배치」 (2026-10-24): a shorter one gives the panel
852
+ something to scroll.
853
+ */
832
854
  test('scrolled and a fold opened on one part; another part opens at its top; back, the first is as it was left', async () => {
855
+ const viewport = page().viewport()
856
+ await page().setViewport({ ...viewport, height: 600 })
857
+ try {
858
+ await scrolledAndBack()
859
+ } finally {
860
+ await page().setViewport(viewport)
861
+ }
862
+ })
863
+ async function scrolledAndBack() {
833
864
  await pressRow('upper-arm')
834
865
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
835
866
  /* 「더 보기」 starts open on this page: folded first, so the fold opened below is one this part leaves open. */
@@ -854,7 +885,7 @@ describe('the side panel is found as it was left, per part', () => {
854
885
  await pressRow('upper-arm')
855
886
  await until('upper-arm as it was left', top => screenCheck.editor().selected === 'upper-arm' && Math.abs(screenCheck.shownPanel().scrollTop - top) <= 2, top)
856
887
  assert.deepEqual(await folds(), opened, 'its folds as they were')
857
- })
888
+ }
858
889
  })
859
890
 
860
891
  describe('the fastening window says whether it changes the fastening the part has or fastens it anew', () => {
@@ -1075,12 +1106,15 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1075
1106
 
1076
1107
  test('an edit is judged as it stands on screen: the source the server is sent is the one edited', async () => {
1077
1108
  await pressRow('left')
1078
- const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'left.size.y'))
1109
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
1110
+ await pressTab(ARRANGEMENT)
1111
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'left Y figure.label.size-measure'))
1079
1112
  await height.click({ clickCount: 3 })
1080
1113
  await page().keyboard.type('600')
1081
1114
  await page().keyboard.press('Enter')
1082
1115
  await until('the edit made', () => screenCheck.editor().session.read().fields.find(f => f.id === 'left.size.y')?.value === 600)
1083
1116
  await until('the edited source judged', () => product.calls().filter(c => c.field === 'inspectFigureDraft').at(-1)?.variables.source === screenCheck.editor().session.revisionNow().text)
1117
+ await pressTab(TAB('properties'))
1084
1118
  })
1085
1119
 
1086
1120
  test('a save sends the card\'s picture with the figure', async () => {
@@ -1114,7 +1148,17 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1114
1148
  }
1115
1149
  })
1116
1150
 
1151
+ /* The part's properties fit a tall window since its size moved to 「배치」 (2026-10-24): a shorter one scrolls. */
1117
1152
  test('another figure opened starts its panels clean: a part of the same name opens at its top', async () => {
1153
+ const viewport = page().viewport()
1154
+ await page().setViewport({ ...viewport, height: 600 })
1155
+ try {
1156
+ await anotherFigureClean()
1157
+ } finally {
1158
+ await page().setViewport(viewport)
1159
+ }
1160
+ })
1161
+ async function anotherFigureClean() {
1118
1162
  await open('fig-p', 'arm-clip')
1119
1163
  await pressRow('upper-arm')
1120
1164
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
@@ -1136,7 +1180,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1136
1180
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
1137
1181
  for (let i = 0; i < 3; i++) await page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
1138
1182
  assert.equal(await read(() => screenCheck.shownPanel().scrollTop), 0, 'not where the other figure\'s part was left')
1139
- })
1183
+ }
1140
1184
 
1141
1185
  test('a new figure\'s AI draft puts a request in the assistant\'s input, and sends nothing', async () => {
1142
1186
  await read(() => assistant.forget())
@@ -0,0 +1,24 @@
1
+ /* Manual browser verification. Uses buildPage's FIGURE_MODEL / THINGS_SCENE overrides when provided. */
2
+ import { buildPage } from './build.mjs'
3
+ import { createServer } from 'node:http'
4
+ import { readFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { createRequire } from 'node:module'
7
+ const require = createRequire(import.meta.url)
8
+ const bundle = await buildPage(process.argv[2] ?? 'response-page.js')
9
+ const routes = new Map([
10
+ ['/page.js', [bundle, 'application/javascript']],
11
+ ['/ko.json', [new URL('../../translations/ko.json', import.meta.url), 'application/json']],
12
+ ['/icons.woff2', [require.resolve('material-symbols/material-symbols-outlined.woff2'), 'font/woff2']],
13
+ ['/workers/markdown-webworker.js', [join(dirname(bundle), 'workers/markdown-webworker.js'), 'application/javascript']]
14
+ ])
15
+ const html = `<!doctype html><html lang="ko"><meta charset="utf-8"><title>반응형 치수 — 실제 편집기 로컬 검증</title><style>
16
+ @font-face{font-family:"Material Symbols Outlined";src:url(/icons.woff2)}
17
+ html,body{margin:0;height:100%;font-family:sans-serif}body{display:flex}figure-editor{flex:1;min-width:0;min-height:0}
18
+ </style><body><script type="module" src="/page.js"></script></body></html>`
19
+ createServer((req, res) => {
20
+ const file = routes.get(req.url)
21
+ if (!file && req.url !== '/') { res.writeHead(404); res.end(); return }
22
+ res.setHeader('Content-Type', file ? file[1] : 'text/html; charset=utf-8')
23
+ res.end(file ? readFileSync(file[0]) : html)
24
+ }).listen(Number(process.env.PORT ?? 4391), '127.0.0.1', function () { console.log(`Ready http://127.0.0.1:${this.address().port}/`) })
@@ -23,7 +23,7 @@ function door(): string {
23
23
  a = addFigureBox(a, { id: 'sign', size: { x: 200, y: 100, z: 10 }, position: { x: 0, y: 2200, z: 200 } })
24
24
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', to: { part: 'frame', face: 'top' } })
25
25
  const j = a.joints!.find(k => k.body1.part === 'leaf')!
26
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
26
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', state: { id: 'open' } }] } })
27
27
  a = editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
28
28
  return serializeFigureAsset(a)
29
29
  }
@@ -22,7 +22,7 @@ function doors(): string {
22
22
  a = addFigureBox(a, { id, size: { x: 900, y: 1800, z: 40 }, position: { x, y: 1000, z: 0 } })
23
23
  a = applyFigureAuthoring(a, { kind: 'attach', part: id, to: { part: 'frame', face: 'top' } })
24
24
  const j = a.joints!.find(k => k.body1.part === id)!
25
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: `${id}-open` } } })
25
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', state: { id: `${id}-open` } }] } })
26
26
  a = editFigurePart(a, { kind: 'set-driver', state: `${id}-open`, clip: `${id}-swing`, duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
27
27
  }
28
28
  return serializeFigureAsset(a)
@@ -31,7 +31,7 @@ const store = { load: async (id: string) => ({ id, source: doors() }), save: asy
31
31
  const clipsOf = (s: ReturnType<typeof createFigureProductSession>) => (parseFigureAsset(s.source()) as FigureAsset).drivers?.map(d => d.clip) ?? []
32
32
  const fixed = (s: ReturnType<typeof createFigureProductSession>, part: string) => {
33
33
  const j = (parseFigureAsset(s.source()) as FigureAsset).joints!.find(k => k.body1.part === part)!
34
- return { kind: 'set-joint', joint: { id: j.id, type: 'fixed', body0: j.body0, body1: j.body1, origin: j.origin } } as never
34
+ return { kind: 'set-joint', joint: { id: j.id, body0: j.body0, body1: j.body1, origin: j.origin } } as never
35
35
  }
36
36
 
37
37
  test('refused and named first; agreed, exactly those go in one step; undo brings them back; consent does not outlive it', async () => {
@@ -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
  }