@things-factory/figure-ui 10.1.271 → 10.1.272

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 (79) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/fasten-form.ts +69 -4
  4. package/client/modeller/figure-cavity-drawing.ts +1 -3
  5. package/client/modeller/figure-clips.ts +101 -8
  6. package/client/modeller/figure-fasten-dialog.ts +41 -30
  7. package/client/modeller/figure-fasten-drawing.ts +3 -1
  8. package/client/modeller/figure-first-save.ts +4 -3
  9. package/client/modeller/figure-links.ts +85 -28
  10. package/client/modeller/figure-preview.ts +41 -3
  11. package/client/modeller/figure-size-editor.ts +7 -2
  12. package/client/modeller/first-save.ts +8 -0
  13. package/client/modeller/focus-on-render.ts +4 -0
  14. package/client/modeller/pivot.ts +76 -0
  15. package/client/modeller/size-response.ts +14 -0
  16. package/client/modeller/stage-model.ts +50 -6
  17. package/dist-client/modeller/canvas.d.ts +1 -0
  18. package/dist-client/modeller/canvas.js +3 -2
  19. package/dist-client/modeller/canvas.js.map +1 -1
  20. package/dist-client/modeller/clips.d.ts +21 -4
  21. package/dist-client/modeller/clips.js +69 -4
  22. package/dist-client/modeller/clips.js.map +1 -1
  23. package/dist-client/modeller/fasten-form.d.ts +8 -0
  24. package/dist-client/modeller/fasten-form.js +64 -5
  25. package/dist-client/modeller/fasten-form.js.map +1 -1
  26. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  27. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  28. package/dist-client/modeller/figure-clips.d.ts +9 -0
  29. package/dist-client/modeller/figure-clips.js +104 -8
  30. package/dist-client/modeller/figure-clips.js.map +1 -1
  31. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  32. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  33. package/dist-client/modeller/figure-fasten-drawing.js +3 -1
  34. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  35. package/dist-client/modeller/figure-first-save.js +4 -3
  36. package/dist-client/modeller/figure-first-save.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +9 -0
  38. package/dist-client/modeller/figure-links.js +89 -35
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-preview.js +39 -3
  41. package/dist-client/modeller/figure-preview.js.map +1 -1
  42. package/dist-client/modeller/figure-size-editor.js +7 -3
  43. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  44. package/dist-client/modeller/first-save.d.ts +5 -0
  45. package/dist-client/modeller/first-save.js +7 -0
  46. package/dist-client/modeller/first-save.js.map +1 -1
  47. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  48. package/dist-client/modeller/focus-on-render.js +4 -0
  49. package/dist-client/modeller/focus-on-render.js.map +1 -0
  50. package/dist-client/modeller/pivot.d.ts +12 -0
  51. package/dist-client/modeller/pivot.js +59 -0
  52. package/dist-client/modeller/pivot.js.map +1 -0
  53. package/dist-client/modeller/size-response.d.ts +6 -0
  54. package/dist-client/modeller/size-response.js +14 -0
  55. package/dist-client/modeller/size-response.js.map +1 -1
  56. package/dist-client/modeller/stage-model.d.ts +17 -3
  57. package/dist-client/modeller/stage-model.js +44 -6
  58. package/dist-client/modeller/stage-model.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/test/browser/editing.check.mjs +6 -5
  62. package/test/browser/section.check.mjs +21 -0
  63. package/test/clips.test.ts +49 -4
  64. package/test/fasten-form.test.ts +44 -0
  65. package/test/first-save.test.ts +11 -1
  66. package/test/pivot.test.ts +57 -0
  67. package/test/size-response.test.ts +10 -2
  68. package/test/stage-model.test.ts +23 -1
  69. package/translations/en.json +8 -0
  70. package/translations/es.json +8 -0
  71. package/translations/ja.json +8 -0
  72. package/translations/ko.json +8 -0
  73. package/translations/ms.json +8 -0
  74. package/translations/zh.json +8 -0
  75. package/tutorial/humanoid/README.md +22 -0
  76. package/tutorial/humanoid/chapters.mjs +247 -0
  77. package/tutorial/humanoid/encode.mjs +26 -0
  78. package/tutorial/humanoid/lib.mjs +197 -0
  79. package/tutorial/humanoid/record.mjs +65 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.271",
3
+ "version": "10.1.272",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.271",
46
- "@things-factory/figure-service": "^10.1.271",
45
+ "@things-factory/ai-assistant": "^10.1.272",
46
+ "@things-factory/figure-service": "^10.1.272",
47
47
  "@things-factory/figure-viewer": "^10.1.266",
48
48
  "@things-factory/setting-base": "^10.1.271",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "843bc2608d8c89348ced871777535966758927db"
51
+ "gitHead": "f87fefe0687eedf52a25cda1c54d59efabaf3dc2"
52
52
  }
@@ -353,7 +353,7 @@ describe('the material: colours by purpose, one step each, a glow drawn while dr
353
353
  test('a diameter typed on the arrangement tab is written once when it is left, as the radius it halves to', async () => {
354
354
  await click(await read(() => probe.tab('figure.label.tab-short-response')))
355
355
  const start = await revision()
356
- await type('drum X · figure.label.size-diameter figure.label.size-measure', '600')
356
+ await type('drum X·Z · figure.label.size-diameter figure.label.size-measure', '600')
357
357
  assert.equal(await revision(), start, 'nothing written yet')
358
358
  await page().keyboard.press('Enter')
359
359
  await sleep(400)
@@ -514,7 +514,8 @@ describe('a clip scrubbed and keyed (2026-09-24)', () => {
514
514
  const typeInClips = async (label, text) => {
515
515
  const field = await page().evaluateHandle(label => {
516
516
  const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
517
- return deep().find(i => i.type === 'number' && i.getAttribute('aria-label') === label && i.getRootNode().host?.localName === 'figure-clips') ?? null
517
+ /* The clip's own field, not the pose bar's above the clips: the bar keys the clip last scrubbed (tutorial 2026-11-12). */
518
+ return deep().find(i => i.type === 'number' && i.getAttribute('aria-label') === label && i.getRootNode().host?.localName === 'figure-clips' && !i.closest('div[pose-bar]')) ?? null
518
519
  }, label)
519
520
  assert.ok(await field.evaluate(i => !!i), `${label} in the clips`)
520
521
  await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
@@ -576,12 +577,12 @@ describe('a track moved round its loop, a clip copied (robot dog 2026-11-03)', (
576
577
  await button.click()
577
578
  }
578
579
 
579
- test('a clip in a group is copied in one step; one in none is not offered', async () => {
580
- /* The left door's clip is in no group, the right one's is in one. */
580
+ test('a clip in a group is copied in one step; one in none is offered too, and makes its group (review 2026-11-11)', async () => {
581
+ /* The left door's clip is in no group, the right one's is in one: both may be copied now. */
581
582
  assert.equal(await read(() => {
582
583
  const deep = (root = document) => [...root.querySelectorAll('button[copy-clip]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
583
584
  return deep().map(b => b.disabled).join()
584
- }), 'true,false')
585
+ }), 'false,false')
585
586
  const start = await revision()
586
587
  await pressIn('button[copy-clip]', 1)
587
588
  await until(async () => (await revision()) === start + 1, 'one step')
@@ -748,6 +748,27 @@ describe('the fastening drawing in the fastening window', () => {
748
748
  assert.deepEqual(joint.body0, { part: 'frame', face: 'front' })
749
749
  assert.deepEqual([joint.body1, joint.origin?.facing], [{ part: 'leaf' }, 'meet'], 'set on the front by its real shape (ADR-0140 3.2)')
750
750
  })
751
+
752
+ /* Tutorial 2026-11-11: the field a size opened did not take the keys; 600 typed and Enter fastened the part where it was. */
753
+ test('a size pressed in the drawing opens its field with the keys in it: typed and Enter moves the part there, the window stays', async () => {
754
+ await change()
755
+ await until(() => !!findDeep('text[dim]', { within: 'figure-fasten-drawing' }), 'a size in the drawing')
756
+ const start = await state()
757
+ await press('text[dim]', { within: 'figure-fasten-drawing' })
758
+ await until(() => {
759
+ const field = findDeep('div[where] input', { within: 'figure-fasten-drawing' })
760
+ return !!field && field.getRootNode().activeElement === field
761
+ }, 'the size\'s field, with the keys in it')
762
+ await page().keyboard.type('37')
763
+ await page().keyboard.press('Enter')
764
+ await sleep(300)
765
+ assert.ok(await read(() => !!probe.fastenTargets()), 'the window stays open')
766
+ assert.equal((await state()).revision, start.revision, 'nothing written before 적용')
767
+ await press('[apply]', { within: 'figure-fasten-dialog' })
768
+ await until(r => probe.state().revision === r + 1, 'one step', start.revision)
769
+ const align = (await read(() => probe.fastening('leaf'))).origin?.align ?? {}
770
+ assert.ok(Object.values(align).some(a => a?.mm === 37), `the typed size: ${JSON.stringify(align)}`)
771
+ })
751
772
  })
752
773
 
753
774
  describe('the face choice: asked where the edit was made, and the same edit goes again with it', () => {
@@ -93,7 +93,7 @@ test('a track copied onto another control a while later; a clip copy takes a fre
93
93
  })
94
94
 
95
95
  /* Review 2026-11-09: a track starts from the pose, a pose is kept in one step, a clip on a played control joins a group. */
96
- import { newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
96
+ import { copyClipEdits, newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
97
97
 
98
98
  const applied = (asset: FigureAsset, edits: unknown[]) => edits.reduce<FigureAsset>((a, e) => editFigurePart(a, e as never), asset)
99
99
 
@@ -103,7 +103,7 @@ test('새 트랙은 지금 자세를 처음부터 끝까지 유지 — 범위
103
103
  assert.deepEqual(newTrackKeys({ min: 5, max: 9 }, undefined, 2), [{ seconds: 0, value: 5 }, { seconds: 2, value: 5 }], '값을 모르면 범위의 시작')
104
104
  /* The panel's wiring: the default button holds the pose, the range one is the preset. */
105
105
  const panel = read('../client/modeller/figure-clips.ts')
106
- assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\)\)/)
106
+ assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\), 'smooth'\)/)
107
107
  assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration, 'range'\)/)
108
108
  /* Several edits go up as one step, and the editor applies them as one. */
109
109
  assert.match(read('../client/modeller/editor.ts'), /<figure-clips[\s\S]*?@part-edits=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.applyRecipe\(e\.detail\.edits\)\)\}[\s\S]*?<\/figure-clips>/)
@@ -128,13 +128,36 @@ test('재생 중인 조작값에 새 클립 — 그룹 없는 클립과 새 동
128
128
  assert.deepEqual(applied(grouped, half.edits).motionGroups!.map(g => g.clips), [['닫기', '열기', '반열기']])
129
129
  })
130
130
 
131
+ test('이 자세 기억하기 — 되풀이 트랙의 처음과 끝은 한 순간: 둘 중 어디서 기억해도 두 키가 함께 바뀐다', () => {
132
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
133
+ const keysOf = (a: FigureAsset) => clipsOf(a)[0]!.tracks[0]!.keys
134
+ assert.equal(clipsOf(asset)[0]!.tracks[0]!.loop, 'wrap')
135
+
136
+ /* Kept at the end: the end reads as the start, yet the pose is kept -- at both ends, so the loop does not jump. */
137
+ const atEnd = rememberedPoseEdits(asset, 'walk', 4, { shift: 20, spin: 0 }, { shift: 0, spin: 0 })
138
+ assert.equal(atEnd.edits.length, 1)
139
+ assert.deepEqual(keysOf(applied(asset, atEnd.edits)), [{ seconds: 0, value: 20 }, { seconds: 2, value: 60 }, { seconds: 4, value: 20 }])
140
+
141
+ /* Kept at 0: the end key follows. */
142
+ const atStart = rememberedPoseEdits(asset, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 })
143
+ assert.deepEqual(keysOf(applied(asset, atStart.edits)), [{ seconds: 0, value: 10 }, { seconds: 2, value: 60 }, { seconds: 4, value: 10 }])
144
+
145
+ /* Both ends already there: nothing to keep. */
146
+ assert.deepEqual(rememberedPoseEdits(asset, 'walk', 4, { shift: 0, spin: 0 }, { shift: 0, spin: 0 }).edits, [])
147
+
148
+ /* A loop left open on purpose -- a ramp that starts over -- keeps its other end. */
149
+ const ramp = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
150
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 4, { shift: 50, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 0 }, { seconds: 4, value: 50 }])
151
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 10 }, { seconds: 4, value: 60 }])
152
+ })
153
+
131
154
  test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른 동작이 쓰는 조작값은 이름만', () => {
132
155
  const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
133
156
  const base = { shift: 30, spin: 0 }
134
157
  /* At 2 s the clip has shift at 30: unchanged, no key. spin moved to 45 since: a new track from 0 to 45 at 2 s. */
135
158
  const kept = rememberedPoseEdits(asset, 'cycle', 2, { shift: 30, spin: 45 }, base)
136
159
  assert.deepEqual(kept.skipped, [])
137
- assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }] }])
160
+ assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }], interpolation: 'smooth' }])
138
161
  const after = applied(asset, kept.edits)
139
162
  assert.equal(poseAt(after, 'cycle', 2).find(c => c.input === 'spin')!.value, 45)
140
163
 
@@ -148,7 +171,7 @@ test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른
148
171
  assert.deepEqual(rememberedPoseEdits(after, 'cycle', 2, { shift: 30, spin: 45 }, { shift: 30, spin: 45 }).edits, [])
149
172
 
150
173
  /* At 0 a new track holds the pose throughout. */
151
- assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }] }])
174
+ assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }], interpolation: 'smooth' }])
152
175
 
153
176
  /* spin played by another clip in no group: not taken, named. */
154
177
  const other = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'turn', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 90 }] })
@@ -174,3 +197,25 @@ test('키 사이 움직임 — 트랙마다 일정한 속도 · 부드럽게 ·
174
197
  assert.match(panel, /\['linear', 'smooth', 'step'\] as const/)
175
198
  assert.match(panel, /\?disabled=\$\{this\.locked \|\| same \|\| !!fits\}/)
176
199
  })
200
+
201
+ test('a clip in no group is copied in one step: a group is made with a name of its own, and both are in it (review 2026-11-11)', () => {
202
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
203
+ const after = applied(asset, copyClipEdits(asset, 'walk'))
204
+ assert.deepEqual(clipsOf(after).map(c => c.name), ['walk', 'walk-copy'])
205
+ assert.deepEqual(after.motionGroups!.map(g => [g.label, g.clips, g.defaultClip]), [['motion', ['walk', 'walk-copy'], 'walk']])
206
+ /* A second group takes the next free name. */
207
+ const taken = { ...asset, motionGroups: [{ id: 'motion', label: 'motion', clips: ['lift'], defaultClip: 'lift', transitionSeconds: 0.2 }] }
208
+ assert.equal((copyClipEdits(taken, 'walk', 'motion')[0] as { group: { label: string } }).group.label, 'motion 2')
209
+ /* Already in a group: only the copy. */
210
+ assert.equal(copyClipEdits(after, 'walk').length, 1)
211
+ })
212
+
213
+ test('a clip lists its tracks in the order of the controls, however its drivers were last written (tutorial 2026-11-12)', () => {
214
+ const keys = [{ seconds: 0, value: 0 }, { seconds: 2, value: 10 }]
215
+ let asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 2, keys })
216
+ asset = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'walk', duration: 2, keys })
217
+ /* The first track keyed again: its driver is written anew. */
218
+ const shift = clipsOf(asset)[0]!.tracks.find(t => t.state === 'shift')!
219
+ asset = editFigurePart(asset, trackEdit(clipsOf(asset)[0]!, shift, { keys: withKey(shift.keys, { seconds: 1, value: 5 }) }) as never)
220
+ assert.deepEqual(clipsOf(asset)[0]!.tracks.map(t => t.state), ['shift', 'spin'])
221
+ })
@@ -204,6 +204,23 @@ test("pressed on the canvas: the face or the point pressed, read in that part's
204
204
  assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
205
205
  })
206
206
 
207
+ test('a part still where the palette put it goes to the centre of the face; one the author moved keeps its place across it (review 2026-11-11)', () => {
208
+ /* A thigh 85 mm left of the middle of a pelvis 880 mm up; a shin and an arm added from the palette at (0, 50, 0). */
209
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
210
+ a = applyFigureAuthoring(box(a, 'thigh', [130, 420, 140], [-85, 670, 0]), { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' }, facing: 'meet', align: { x: { mm: -85 } } } as never)
211
+ a = box(box(a, 'shin', [110, 420, 120], [0, 50, 0]), 'arm', [90, 300, 90], [0, 50, 0])
212
+ const r = revisionOf(a)
213
+ const shin = aimFasten(r, 'shin', fastenFormOf(r, 'shin'), { part: 'thigh' })
214
+ const onBottom = aimFasten(r, 'shin', shin, { targetFace: 'bottom' })
215
+ assert.deepEqual([onBottom.align, onBottom.alignMm], [{}, {}], 'on the centre of the thigh, not 85 mm off it')
216
+ const arm = aimFasten(r, 'arm', aimFasten(r, 'arm', fastenFormOf(r, 'arm'), { part: 'pelvis' }), { targetFace: 'left' })
217
+ assert.deepEqual([arm.align, arm.alignMm], [{}, {}], 'on the side, not 920 mm below it')
218
+ /* Moved by the author first, it keeps where it stands across the face. */
219
+ const moved = revisionOf(box(a, 'tag', [40, 40, 40], [-60, 500, 30]))
220
+ const kept = aimFasten(moved, 'tag', aimFasten(moved, 'tag', fastenFormOf(moved, 'tag'), { part: 'thigh' }), { targetFace: 'bottom' })
221
+ assert.deepEqual(kept.alignMm, { x: 25, z: 30 })
222
+ })
223
+
207
224
  test('a part still where the palette put it is fastened on the face; one the author moved keeps its place (tutorial 2026-11-11)', () => {
208
225
  /* A body on the floor; a mast added from the palette stands inside it, at the palette's place (0, 50, 0). */
209
226
  let a = fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane')
@@ -216,3 +233,30 @@ test('a part still where the palette put it is fastened on the face; one the aut
216
233
  const kept = keptGapOf(moved, 'mast', aimFasten(moved, 'mast', fastenFormOf(moved, 'mast'), { part: 'body' }))
217
234
  assert.equal(kept.gap, 50, 'the 50 mm its bottom stands above the body')
218
235
  })
236
+
237
+ test('aimed at a part, the form starts on a face nothing is fastened to yet (review 2026-11-11)', () => {
238
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
239
+ a = applyFigureAuthoring(box(a, 'torso', [340, 500, 200], [0, 50, 0]), { kind: 'attach', part: 'torso', to: { part: 'pelvis', face: 'top' } } as never)
240
+ a = box(a, 'thigh', [130, 420, 140], [0, 50, 0])
241
+ const r = revisionOf(a)
242
+ const thigh = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'pelvis' })
243
+ assert.equal(thigh.targetFace, 'bottom', 'across from the top, where the body stands')
244
+ assert.equal(aimFasten(r, 'torso', fastenFormOf(r, 'torso'), { part: 'pelvis' }).targetFace, 'top', 'a part keeps the face it is fastened to')
245
+ const head = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'torso' })
246
+ assert.equal(head.targetFace, 'top', 'a free top is taken as asked')
247
+ /* A shin aimed at a thigh hung under the pelvis: the thigh's top touches the pelvis, so the shin goes under it. */
248
+ let b = applyFigureAuthoring(a, { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' } } as never)
249
+ b = box(b, 'shin', [110, 420, 120], [0, 50, 0])
250
+ const rb = revisionOf(b)
251
+ assert.equal(aimFasten(rb, 'shin', fastenFormOf(rb, 'shin'), { part: 'thigh' }).targetFace, 'bottom')
252
+ /* A forearm aimed at an upper arm hung by its side from the torso: on down, under the arm (tutorial run 2026-11-12). */
253
+ let c = applyFigureAuthoring(box(a, 'upperarm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'upperarm', to: { part: 'torso', face: 'left' } } as never)
254
+ c = box(c, 'forearm', [80, 280, 80], [0, 50, 0])
255
+ const rc = revisionOf(c)
256
+ assert.equal(aimFasten(rc, 'forearm', fastenFormOf(rc, 'forearm'), { part: 'upperarm' }).targetFace, 'bottom')
257
+ /* A flat part hung by its side is not a limb: what goes on it keeps the face asked. */
258
+ let d = applyFigureAuthoring(box(a, 'shelf', [300, 40, 200], [0, 50, 0]), { kind: 'attach', part: 'shelf', to: { part: 'torso', face: 'left' } } as never)
259
+ d = box(d, 'cup', [60, 80, 60], [0, 50, 0])
260
+ const rd = revisionOf(d)
261
+ assert.equal(aimFasten(rd, 'cup', fastenFormOf(rd, 'cup'), { part: 'shelf' }).targetFace, 'top')
262
+ })
@@ -5,7 +5,7 @@ import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
 
7
7
  import { placeholderType } from '@hatiolab/figure-model'
8
- import { firstSaveRefusalOf, releasableType, typeOf } from '../client/modeller/first-save.ts'
8
+ import { firstSaveRefusalOf, releasableType, typeOf, typingTypeOf } from '../client/modeller/first-save.ts'
9
9
  import { createFigureProductSession, type FigureProductStore } from '../client/modeller/product-session.ts'
10
10
 
11
11
  test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이픈 하나', () => {
@@ -14,6 +14,16 @@ test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이
14
14
  assert.equal(typeOf('-Roller Conveyor-'), 'roller-conveyor')
15
15
  })
16
16
 
17
+ test('하이픈을 칠 수 있다 — 한 글자씩 쳐도 끝의 하이픈이 남고, 저장할 때 다듬는다', () => {
18
+ let typed = ''
19
+ for (const key of 'fork-agv-tutorial') typed = typingTypeOf(typed + key)
20
+ assert.equal(typed, 'fork-agv-tutorial')
21
+ let trailing = ''
22
+ for (const key of 'Fork AGV ') trailing = typingTypeOf(trailing + key)
23
+ assert.equal(trailing, 'fork-agv-')
24
+ assert.equal(typeOf(trailing), 'fork-agv')
25
+ })
26
+
17
27
  test('발행할 수 없는 이름은 미리 말한다 — 임시 이름 · 형식 · 길이', () => {
18
28
  assert.equal(releasableType('agv-lifter'), true)
19
29
  assert.equal(releasableType(placeholderType()), false, '서버가 붙이는 임시 이름')
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Named points a turn's axis may pass (`pivot.ts`): worked out from the part as it rests, they keep the named point
3
+ * still as the joint turns -- whatever the face, the alignment, the slider.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
9
+ import type { FigureAsset } from '@hatiolab/figure-model'
10
+
11
+ import { motionJoint } from '../client/modeller/links.ts'
12
+ import { pivotPointOf, pivotPointsOf } from '../client/modeller/pivot.ts'
13
+ import { revisionOf } from './revision-of.ts'
14
+
15
+ const box = (a: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(a, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
16
+ const turn = (through = {}) => ({ kind: 'turn', axis: 'x', id: 'sh', min: -90, max: 90, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0, through }) as never
17
+
18
+ /* An arm of 300 mm hung by its side from a torso, level with its top -- the lesson's upper arm -- or under it. */
19
+ function arm(face: 'left' | 'bottom', align?: object) {
20
+ const a = box(createFigureAsset('H'), 'torso', [340, 500, 200], [0, 1000, 0])
21
+ return applyFigureAuthoring(box(a, 'arm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'arm', to: { part: 'torso', face }, ...(align ? { align } : {}) } as never)
22
+ }
23
+ const endsAt = (a: FigureAsset, through: object, value: number) => {
24
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
25
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(through), 'arm') } as never)
26
+ const g = (revisionOf(b, { sh: value }).geometry as Array<{ id: string; pose: { r: number[][]; t: number[] } }>).find(p => p.id === 'arm')!.pose
27
+ /* The arm's top and bottom end centres in the figure's frame. */
28
+ return [150, -150].map(y => [0, 1, 2].map(i => g.r[i]![1]! * y + g.t[i]!))
29
+ }
30
+ const near = (a: number[], b: number[]) => a.every((v, i) => Math.abs(v - b[i]!) < 0.5)
31
+
32
+ test('a named pivot stays where it is as the joint turns: top, middle, bottom of a side-hung or an under-hung arm (tutorial 2026-11-12)', () => {
33
+ for (const [face, align] of [['left', { y: 'max' }], ['left', undefined], ['bottom', undefined]] as const) {
34
+ const a = arm(face, align)
35
+ const points = pivotPointsOf(revisionOf(a), 'arm', 'x')!
36
+ assert.ok(points, `${face}: points`)
37
+ const [top0, bottom0] = endsAt(a, points.top, 0)
38
+ assert.ok(near(endsAt(a, points.top, 70)[0]!, top0!), `${face} ${JSON.stringify(align)}: the top end stays`)
39
+ assert.ok(near(endsAt(a, points.bottom, 70)[1]!, bottom0!), `${face} ${JSON.stringify(align)}: the bottom end stays`)
40
+ assert.ok(!near(endsAt(a, points.middle, 70)[0]!, top0!), `${face}: about the middle the top end moves`)
41
+ }
42
+ })
43
+
44
+ test('the lesson upper arm, level with the torso top, already turns about its top end: no number is needed', () => {
45
+ const points = pivotPointsOf(revisionOf(arm('left', { y: 'max' })), 'arm', 'x')!
46
+ assert.deepEqual(points, { top: {}, middle: { y: -150 }, bottom: { y: -300 } })
47
+ assert.equal(pivotPointOf(points, {}, 'x'), 'top')
48
+ assert.equal(pivotPointOf(points, { y: 120 }, 'x'), undefined, 'Y 120 put the axis above the shoulder')
49
+ })
50
+
51
+ test('a part fastened to nothing, or a slider left turned, has no wrong points', () => {
52
+ assert.equal(pivotPointsOf(revisionOf(box(createFigureAsset('H'), 'solo', [10, 10, 10])), 'solo'), null)
53
+ const a = arm('bottom')
54
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
55
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(), 'arm') } as never)
56
+ assert.deepEqual(pivotPointsOf(revisionOf(b, { sh: 60 }), 'arm'), pivotPointsOf(revisionOf(b), 'arm'))
57
+ })
@@ -1,7 +1,8 @@
1
1
  import { test } from 'node:test'
2
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'
3
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset, figureRowOf, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { sizeAxisWord, sizeRelationSteps, sizeRelationOf, sizeInputOf } from '../client/modeller/size-response.ts'
5
+ import { followableAxes } from '../client/modeller/links.ts'
5
6
  import type { FigureRevision } from '../client/modeller/revision.ts'
6
7
  import { multipartResponseFixture } from './multipart-response-fixture.js'
7
8
  const revision = (asset: ReturnType<typeof multipartResponseFixture>) => ({ asset, inputs: asset.designInputs } as FigureRevision)
@@ -47,3 +48,10 @@ test('repeat settings can change count, direction and mode without losing the at
47
48
  assert.deepEqual(a.joints!.find(j=>j.body1.part==='legLeft')!.body0,initial.body0)
48
49
  assert.deepEqual(figureFollowOf(a,'brace','x'),{of:'part',part:'tabletop',axis:'x',plus:-200})
49
50
  })
51
+
52
+ test('a round body names the diameter that sets the axis it has no row for: X·Z across, Y up (tutorial 2026-11-12)', () => {
53
+ const ball = addFigurePrimitive(createFigureAsset('Ball'), { id: 'ball', kind: 'sphere', dimensions: { radius: 100, radiusY: 120 }, position: { x: 0, y: 0, z: 0 } } as never)
54
+ assert.deepEqual(followableAxes(ball, 'ball').map(a => sizeAxisWord(ball, 'ball', a, '지름')), ['X·Z · 지름', 'Y · 지름'])
55
+ const block = addFigureBox(createFigureAsset('Block'), { id: 'block', size: { x: 10, y: 20, z: 30 }, position: { x: 0, y: 0, z: 0 } })
56
+ assert.deepEqual(followableAxes(block, 'block').map(a => sizeAxisWord(block, 'block', a, '지름')), ['X', 'Y', 'Z'])
57
+ })
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
9
9
  import { addFigureBox, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
10
10
 
11
11
  import { drawableFigureParts } from '../client/modeller/renderer.ts'
12
- import { boardFor, needsRefit } from '../client/modeller/stage-model.ts'
12
+ import { boardFor, drawnSpanOf, needsRefit, outgrowsFit } from '../client/modeller/stage-model.ts'
13
13
 
14
14
  const partsOf = (height: number) =>
15
15
  drawableFigureParts(
@@ -33,3 +33,25 @@ test('a size typed far from the view brings the camera to it; a nudge does not (
33
33
  assert.equal(needsRefit(boardFor(parts(3000, 800, 600)), boardFor(parts(100, 100, 100))), true, 'shrunk far: fitted again')
34
34
  assert.equal(needsRefit(undefined, boardFor(parts(3000, 800, 600))), false, 'never fitted: the first standing fits it')
35
35
  })
36
+
37
+ test('the view follows a figure that grows past the fit, or stands higher off the floor (review 2026-11-11)', () => {
38
+ const at = (height: number, y: number) =>
39
+ drawableFigureParts(compileFigureAsset(addFigureBox(createFigureAsset('P'), { id: 'p', size: { x: 300, y: height, z: 200 }, position: { x: 0, y, z: 0 } })).evaluate().geometry)
40
+ /* A pelvis 180 mm tall put 880 mm up: as tall as it stands from the floor, not as its own height. */
41
+ const onFloor = boardFor(at(180, 90))
42
+ const raised = boardFor(at(180, 970))
43
+ assert.ok(raised.depth >= 1060, `reached from the floor: ${raised.depth}`)
44
+ assert.equal(needsRefit(onFloor, raised), true, 'raised far: the view follows it up')
45
+ /* A head added on a 1.56 m body: 1.8 m runs past the fit's margin. */
46
+ assert.equal(needsRefit(boardFor(at(680, 1220)), boardFor(at(920, 1340))), true)
47
+ /* A nudge inside the fit's margin leaves the camera where the author put it. */
48
+ assert.equal(needsRefit(boardFor(at(680, 1220)), boardFor(at(700, 1230))), false)
49
+ })
50
+
51
+ test('the view follows a part hung below what it was fitted round (review 2026-11-11)', () => {
52
+ const body = drawnSpanOf(partsOf(500))
53
+ const longer = drawnSpanOf(partsOf(800))
54
+ assert.equal(outgrowsFit(body, longer), true, 'reaching 300 mm past the top')
55
+ assert.equal(outgrowsFit(body, drawnSpanOf(partsOf(520))), false, 'a little taller stays in view')
56
+ assert.equal(outgrowsFit(undefined, longer), false)
57
+ })
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "sets the anchor plane in 3D scene (floor: AGV/conveyor, ceiling: OHT/hoist, center: sensor/drone).",
486
486
  "figure.text.pose-as-default-explained": "Writes the pose you have set as the parameters’ defaults, so the figure stands like this when nobody gives it a value.",
487
+ "figure.text.control-played": "Playing",
488
+ "figure.text.control-played-explained": "The {clip} clip is moving this control. Stop the clip to give it a value.",
487
489
  "figure.text.clear-tried-explained": "clears the control values tried here. the saved default pose is unchanged, and the clips move the figure again.",
488
490
  "figure.text.preview-blocked-by-errors": "cannot show the preview because of format errors. see the check tab for the reason.",
489
491
  "figure.text.put-on-floor": "Moves the part once so its bottom is on the floor (Y=0). It does not stay there when the size changes; for that, use “Follow size, attached to the floor”.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "its own control",
716
718
  "figure.label.motion-multiplier": "multiplier",
717
719
  "figure.label.motion-offset": "offset",
720
+ "figure.label.pivot-at": "turn about",
721
+ "figure.label.pivot-top": "top end",
722
+ "figure.label.pivot-middle": "middle",
723
+ "figure.label.pivot-bottom": "bottom end",
718
724
  "figure.label.motion-through": "axis passes through",
719
725
  "figure.label.motion-follows": "moves with {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "opening left",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} is already the name of a place in this figure.",
1021
1027
  "figure.label.placement": "fastening",
1022
1028
  "figure.label.motion-groups": "motion groups",
1029
+ "figure.label.motion-group-named": "motion",
1023
1030
  "figure.label.motion-group": "motion group",
1024
1031
  "figure.label.no-motion-group": "no group",
1025
1032
  "figure.label.motion-group-name": "name",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "shift",
1323
1330
  "figure.button.copy-track-apply": "copy",
1324
1331
  "figure.text.copy-clip-needs-group": "A copy plays the same controls, so only a clip in a motion group can be copied.",
1332
+ "figure.text.copy-clip-makes-group": "Copies every track into a clip with a new name. The copy moves the same controls, so both go into a new motion group named after this clip (one plays at a time).",
1325
1333
  "figure.label.primitive-pipe": "pipe",
1326
1334
  "figure.label.primitive-duct": "duct",
1327
1335
  "figure.label.primitive-tapered-box": "tapered box",
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "elija una forma arriba para añadir la primera pieza.",
485
485
  "figure.text.placement-explained": "define el plano de anclaje en la escena 3D (suelo: AGV/transportador, techo: OHT/polipasto, centro: sensor/dron).",
486
486
  "figure.text.pose-as-default-explained": "Escribe la postura que ha definido como valores predeterminados de los parámetros, para que la figura quede así cuando nadie le dé un valor.",
487
+ "figure.text.control-played": "En reproducción",
488
+ "figure.text.control-played-explained": "El clip {clip} está moviendo este control. Detén el clip para darle un valor.",
487
489
  "figure.text.clear-tried-explained": "borra los valores de control probados aquí. la pose por defecto guardada no cambia y los clips vuelven a moverla.",
488
490
  "figure.text.preview-blocked-by-errors": "no se puede mostrar la vista previa por errores de formato. consulte el motivo en la pestaña de madurez.",
489
491
  "figure.text.put-on-floor": "Mueve la pieza una vez para que su base quede en el suelo (Y=0). No se mantiene ahí cuando cambia el tamaño; para ello, use “Seguir el tamaño, unida al suelo”.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "su propio control",
716
718
  "figure.label.motion-multiplier": "multiplicador",
717
719
  "figure.label.motion-offset": "desplazamiento",
720
+ "figure.label.pivot-at": "girar en",
721
+ "figure.label.pivot-top": "extremo superior",
722
+ "figure.label.pivot-middle": "centro",
723
+ "figure.label.pivot-bottom": "extremo inferior",
718
724
  "figure.label.motion-through": "el eje pasa por",
719
725
  "figure.label.motion-follows": "se mueve con {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "abertura izquierda",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} ya es el nombre de un lugar en esta figura.",
1021
1027
  "figure.label.placement": "sujeción",
1022
1028
  "figure.label.motion-groups": "grupos de movimiento",
1029
+ "figure.label.motion-group-named": "movimiento",
1023
1030
  "figure.label.motion-group": "grupo de movimiento",
1024
1031
  "figure.label.no-motion-group": "sin grupo",
1025
1032
  "figure.label.motion-group-name": "nombre",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "desplazar",
1323
1330
  "figure.button.copy-track-apply": "copiar",
1324
1331
  "figure.text.copy-clip-needs-group": "Una copia mueve los mismos controles, así que solo se copia un clip que está en un grupo de movimiento.",
1332
+ "figure.text.copy-clip-makes-group": "Copia todas las pistas en un clip con un nombre nuevo. La copia mueve los mismos controles, así que ambos van a un grupo de movimiento nuevo con el nombre de este clip (se reproduce uno a la vez).",
1325
1333
  "figure.label.primitive-pipe": "tubo",
1326
1334
  "figure.label.primitive-duct": "conducto",
1327
1335
  "figure.label.primitive-tapered-box": "caja ahusada",
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "3D シーンでの基準面を決めます (床: AGV/コンベヤ、天井: OHT/ホイスト、中心: センサー/ドローン)。",
486
486
  "figure.text.pose-as-default-explained": "今の姿勢をパラメータの既定値として書き込みます。値が与えられないとき、この姿勢で立ちます。",
487
+ "figure.text.control-played": "再生中",
488
+ "figure.text.control-played-explained": "{clip} クリップがこの操作値を動かしています。クリップを止めると値を与えられます。",
487
489
  "figure.text.clear-tried-explained": "プレビューで変えた操作値を消します。保存された既定の姿勢は変わらず、クリップが再び動かします。",
488
490
  "figure.text.preview-blocked-by-errors": "形式エラーのためプレビューを表示できません。成熟度タブで理由を確認してください。",
489
491
  "figure.text.put-on-floor": "今の位置だけ底面を床(Y=0)に合わせます。サイズが変わると追従しません。付けたままにするには「床に付けてサイズに追従」を使ってください。",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "自分の操作値",
716
718
  "figure.label.motion-multiplier": "倍率",
717
719
  "figure.label.motion-offset": "オフセット",
720
+ "figure.label.pivot-at": "回転の中心",
721
+ "figure.label.pivot-top": "上端",
722
+ "figure.label.pivot-middle": "中央",
723
+ "figure.label.pivot-bottom": "下端",
718
724
  "figure.label.motion-through": "軸が通る点",
719
725
  "figure.label.motion-follows": "{joint} に連動 × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "開口部 左",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} はこの図形ですでに使われている場所の名前です。",
1021
1027
  "figure.label.placement": "取り付け",
1022
1028
  "figure.label.motion-groups": "モーショングループ",
1029
+ "figure.label.motion-group-named": "動作",
1023
1030
  "figure.label.motion-group": "モーショングループ",
1024
1031
  "figure.label.no-motion-group": "グループなし",
1025
1032
  "figure.label.motion-group-name": "名前",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "ずらす",
1323
1330
  "figure.button.copy-track-apply": "複製",
1324
1331
  "figure.text.copy-clip-needs-group": "複製は同じ操作値を動かすため、動作グループに入れたクリップだけ複製できます。",
1332
+ "figure.text.copy-clip-makes-group": "すべてのトラックを新しい名前のクリップへ複製します。複製は同じ操作値を動かすため、このクリップの名前で動作グループを作り、両方を入れます(同時に再生されるのは一つ)。",
1325
1333
  "figure.label.primitive-pipe": "パイプ",
1326
1334
  "figure.label.primitive-duct": "ダクト",
1327
1335
  "figure.label.primitive-tapered-box": "先細りの箱",
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "위에서 하나를 골라 첫 부품을 놓으세요.",
485
485
  "figure.text.placement-explained": "3D 씬에서 객체의 기준면을 정합니다 (바닥: AGV/컨베이어, 천장: OHT/호이스트, 중심: 센서/드론).",
486
486
  "figure.text.pose-as-default-explained": "현재 자세를 파라미터의 기본값으로 저장합니다. 값이 지정되지 않으면 피규어에 이 자세가 적용됩니다.",
487
+ "figure.text.control-played": "재생 중",
488
+ "figure.text.control-played-explained": "{clip} 클립이 이 조작값을 움직이고 있습니다. 클립을 멈추면 값을 줄 수 있습니다.",
487
489
  "figure.text.clear-tried-explained": "미리 보기에서 바꾼 조작값을 지웁니다. 저장된 기본 자세는 바뀌지 않고, 클립이 다시 움직입니다.",
488
490
  "figure.text.preview-blocked-by-errors": "형식 오류로 미리보기를 표시할 수 없습니다. 검사 탭에서 사유를 확인하세요.",
489
491
  "figure.text.put-on-floor": "지금 위치만 바닥(Y=0)에 맞춥니다. 크기가 바뀌면 따라가지 않습니다. 계속 붙어 있게 하려면 ‘바닥에 붙여 크기 따라가기’를 쓰세요.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "제 조작값으로",
716
718
  "figure.label.motion-multiplier": "배율",
717
719
  "figure.label.motion-offset": "더하기",
720
+ "figure.label.pivot-at": "회전 중심",
721
+ "figure.label.pivot-top": "위 끝",
722
+ "figure.label.pivot-middle": "가운데",
723
+ "figure.label.pivot-bottom": "아래 끝",
718
724
  "figure.label.motion-through": "축이 지나는 점",
719
725
  "figure.label.motion-follows": "따라 움직임: {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "개구부 왼쪽",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "이 피규어에서 이미 쓰는 자리 이름입니다: {slot}.",
1021
1027
  "figure.label.placement": "붙이기",
1022
1028
  "figure.label.motion-groups": "동작 그룹",
1029
+ "figure.label.motion-group-named": "동작",
1023
1030
  "figure.label.motion-group": "동작 그룹",
1024
1031
  "figure.label.no-motion-group": "그룹 없음",
1025
1032
  "figure.label.motion-group-name": "이름",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "시간 밀기",
1323
1330
  "figure.button.copy-track-apply": "복사",
1324
1331
  "figure.text.copy-clip-needs-group": "복사본은 같은 조작값을 움직이므로 동작 묶음에 넣은 클립만 복사할 수 있습니다.",
1332
+ "figure.text.copy-clip-makes-group": "모든 트랙을 새 이름의 클립으로 복사합니다. 복사본은 같은 조작값을 움직이므로, 이 클립의 이름으로 동작 묶음을 만들어 둘을 함께 넣습니다(한 번에 하나만 재생).",
1325
1333
  "figure.label.primitive-pipe": "관",
1326
1334
  "figure.label.primitive-duct": "덕트",
1327
1335
  "figure.label.primitive-tapered-box": "가늘어지는 상자",
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "menetapkan satah rujukan dalam adegan 3D (lantai: AGV/penghantar, siling: OHT/hois, pusat: sensor/dron).",
486
486
  "figure.text.pose-as-default-explained": "Menulis postur semasa sebagai nilai lalai parameter, supaya figura berdiri begini apabila tiada nilai diberi.",
487
+ "figure.text.control-played": "Dimainkan",
488
+ "figure.text.control-played-explained": "Klip {clip} sedang menggerakkan kawalan ini. Hentikan klip untuk memberinya nilai.",
487
489
  "figure.text.clear-tried-explained": "mengosongkan nilai kawalan yang dicuba di sini. pose lalai yang disimpan tidak berubah, dan klip menggerakkannya semula.",
488
490
  "figure.text.preview-blocked-by-errors": "pratonton tidak dapat dipaparkan kerana ralat format. lihat tab kematangan untuk sebabnya.",
489
491
  "figure.text.put-on-floor": "Mengalihkan bahagian sekali supaya bahagian bawahnya di lantai (Y=0). Ia tidak kekal di situ apabila saiz berubah; untuk itu, gunakan “Ikut saiz, dilekatkan pada lantai”.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "kawalan sendiri",
716
718
  "figure.label.motion-multiplier": "pendarab",
717
719
  "figure.label.motion-offset": "ofset",
720
+ "figure.label.pivot-at": "pusing pada",
721
+ "figure.label.pivot-top": "hujung atas",
722
+ "figure.label.pivot-middle": "tengah",
723
+ "figure.label.pivot-bottom": "hujung bawah",
718
724
  "figure.label.motion-through": "paksi melalui",
719
725
  "figure.label.motion-follows": "bergerak bersama {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "bukaan kiri",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} sudah menjadi nama tempat dalam figura ini.",
1021
1027
  "figure.label.placement": "lekatan",
1022
1028
  "figure.label.motion-groups": "kumpulan gerakan",
1029
+ "figure.label.motion-group-named": "gerakan",
1023
1030
  "figure.label.motion-group": "kumpulan gerakan",
1024
1031
  "figure.label.no-motion-group": "tiada kumpulan",
1025
1032
  "figure.label.motion-group-name": "nama",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "anjak",
1323
1330
  "figure.button.copy-track-apply": "salin",
1324
1331
  "figure.text.copy-clip-needs-group": "Salinan menggerakkan kawalan yang sama, jadi hanya klip dalam kumpulan gerakan boleh disalin.",
1332
+ "figure.text.copy-clip-makes-group": "Menyalin semua trek ke klip bernama baharu. Salinan menggerakkan kawalan yang sama, jadi kedua-duanya dimasukkan ke kumpulan gerakan baharu bernama klip ini (satu dimainkan pada satu masa).",
1325
1333
  "figure.label.primitive-pipe": "paip",
1326
1334
  "figure.label.primitive-duct": "salur",
1327
1335
  "figure.label.primitive-tapered-box": "kotak tirus",
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "设置 3D 场景中的基准面 (地面: AGV/输送机,天花板: OHT/吊车,中心: 传感器/无人机)。",
486
486
  "figure.text.pose-as-default-explained": "把当前姿态写为参数默认值。没有给值时,图形以此姿态站立。",
487
+ "figure.text.control-played": "播放中",
488
+ "figure.text.control-played-explained": "{clip} 片段正在驱动此控制值。停止片段后即可赋值。",
487
489
  "figure.text.clear-tried-explained": "清除在预览中修改的控制值。已保存的默认姿态不变,剪辑会再次驱动它。",
488
490
  "figure.text.preview-blocked-by-errors": "由于格式错误无法显示预览。请在成熟度标签页查看原因。",
489
491
  "figure.text.put-on-floor": "只把当前位置的底面对齐到地面(Y=0)。尺寸变化时不会跟随。若要保持附着,请使用“附着地面并跟随尺寸”。",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "自身控制值",
716
718
  "figure.label.motion-multiplier": "倍率",
717
719
  "figure.label.motion-offset": "偏移",
720
+ "figure.label.pivot-at": "转动中心",
721
+ "figure.label.pivot-top": "上端",
722
+ "figure.label.pivot-middle": "中间",
723
+ "figure.label.pivot-bottom": "下端",
718
724
  "figure.label.motion-through": "轴经过的点",
719
725
  "figure.label.motion-follows": "跟随 {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "开口 左侧",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} 已是此图形中某个位置的名称。",
1021
1027
  "figure.label.placement": "附着",
1022
1028
  "figure.label.motion-groups": "动作组",
1029
+ "figure.label.motion-group-named": "动作",
1023
1030
  "figure.label.motion-group": "动作组",
1024
1031
  "figure.label.no-motion-group": "不分组",
1025
1032
  "figure.label.motion-group-name": "名称",
@@ -1322,6 +1329,7 @@
1322
1329
  "figure.button.shift-track": "平移",
1323
1330
  "figure.button.copy-track-apply": "复制",
1324
1331
  "figure.text.copy-clip-needs-group": "副本驱动相同的控制值,因此只能复制已放入动作组的片段。",
1332
+ "figure.text.copy-clip-makes-group": "将所有轨道复制为新名称的片段。副本驱动相同的控制值,因此以此片段的名称创建动作组并放入两者(同一时间只播放一个)。",
1325
1333
  "figure.label.primitive-pipe": "管",
1326
1334
  "figure.label.primitive-duct": "风管",
1327
1335
  "figure.label.primitive-tapered-box": "渐变箱",