@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
@@ -67,7 +67,7 @@ test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는
67
67
  const turning = moving(asset, 'block', { kind: 'turn', axis: 'y', id: 'swing', min: -90, max: 90, start: 0 })
68
68
  assert.deepEqual([motionOf(turning, 'block')!.kind, motionOf(turning, 'block')!.axis], ['turn', 'y'])
69
69
  const joint = sliding.joints!.find(j => j.body1.part === 'block')!
70
- const still = applyFigureAuthoring(sliding, { kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1 } })
70
+ const still = applyFigureAuthoring(sliding, { kind: 'set-joint', joint: { id: joint.id, body0: joint.body0, body1: joint.body1 } })
71
71
  assert.equal(motionOf(still, 'block'), undefined, 'a fixed joint does not move')
72
72
  })
73
73
 
@@ -256,7 +256,7 @@ test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한
256
256
  asset = applyFigureAuthoring(asset, { kind: 'scale-uniformly' })
257
257
  assert.deepEqual(proportionToFollow(asset), { count: 3 + 3 + 1, kept: [] }, '치수 여섯과 자리 하나')
258
258
  const lamp = asset.joints!.find(j => j.id === 'lamp')!
259
- asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...lamp, type: 'prismatic', axis: 'Y', lowerLimit: 0, upperLimit: 50, state: { id: 'rise' } } })
259
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...lamp, motion: [{ type: 'prismatic', axis: 'Y', lowerLimit: 0, upperLimit: 50, state: { id: 'rise' } }] } })
260
260
  asset = applyFigureAuthoring(asset, { kind: 'follow-size-in-proportion' })
261
261
  assert.deepEqual(proportionToFollow(asset), { count: 0, kept: ['lamp.motion'] }, '다 따르고, mm 로 움직이는 것만 남는다')
262
262
  assert.equal(Math.round(valueNow(revisionOf(asset), 'lamp.dimension.x.value')! * 1e6) / 1e6, 60, '따르게 해도 지금 크기는 그대로')
@@ -359,9 +359,9 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
359
359
  assert.deepEqual(leadersFor(asset, 'left', 'slide'), [], '제 관절은 고르지 않는다')
360
360
 
361
361
  const follower = motionJoint(jointOf('right'), { ...form, id: 'ignored', follow: 'left', multiplier: -1 }, 'right')
362
- assert.deepEqual(follower.mimic, { joint: 'left', multiplier: -1 })
363
- assert.equal(follower.state, undefined, '따라가는 관절은 제 조작값이 없다')
364
- assert.equal(follower.lowerLimit, undefined)
362
+ assert.deepEqual(follower.motion![0]!.mimic, { motion: 'left', multiplier: -1 })
363
+ assert.equal(follower.motion![0]!.state, undefined, '따라가는 관절은 제 조작값이 없다')
364
+ assert.equal(follower.motion![0]!.lowerLimit, undefined)
365
365
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: follower })
366
366
  assert.deepEqual(leadersFor(asset, 'wrist', 'slide').map(j => j.id), ['left'], '따라가는 관절은 앞장서지 않는다')
367
367
 
@@ -477,8 +477,8 @@ test('미끄러짐의 이동 거리가 부품 길이를 따른다 — 레일이
477
477
  const joint = asset.joints!.find(j => j.body1.part === 'bridge')!
478
478
  const form = { kind: 'slide' as const, axis: 'x' as const, id: 'x', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: 'rail:x', travelPlus: -160 }
479
479
  const record = motionJoint(joint, form, 'bridge')
480
- assert.deepEqual(record.travel, { source: { of: 'part', part: 'rail', axis: 'x' }, plus: -160 })
481
- assert.equal(record.lowerLimit, undefined, '거리는 레일이 정한다 — mm 범위는 없다')
480
+ assert.deepEqual(record.motion![0]!.travel, { source: { of: 'part', part: 'rail', axis: 'x' }, plus: -160 })
481
+ assert.equal(record.motion![0]!.lowerLimit, undefined, '거리는 레일이 정한다 — mm 범위는 없다')
482
482
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: record })
483
483
  assert.deepEqual(motionOf(asset, 'bridge')?.travel, { part: 'rail', axis: 'x', plus: -160 }, '화면은 무엇을 따르는지 읽는다')
484
484
  const end = (railLength: number) => {
@@ -517,7 +517,7 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
517
517
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
518
518
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
519
519
  const j = asset.joints!.find(o => o.id === 'leaf')!
520
- asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
520
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } }] } })
521
521
  assert.deepEqual(instanceSizeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
522
522
  })
523
523
 
@@ -534,20 +534,21 @@ test('움직임은 그 자리에서 고친다 — 폼에 지금 관절을 채우
534
534
  const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
535
535
  const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b' } }
536
536
  const joints = [
537
- { ...base, type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } },
538
- { ...base, type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } },
539
- { ...base, type: 'revolute' as const, axis: 'Y' as const, state: { id: 'spin' } },
540
- { ...base, type: 'prismatic' as const, axis: 'X' as const, mimic: { joint: 'k', multiplier: -1 } },
541
- { ...base, type: 'prismatic' as const, axis: 'X' as const, travel: { source: { of: 'part' as const, part: 'rail', axis: 'x' as const }, plus: -160 }, state: { id: 'x' } }
537
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } }] },
538
+ { ...base, motion: [{ type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } }] },
539
+ { ...base, motion: [{ type: 'revolute' as const, axis: 'Y' as const, state: { id: 'spin' } }] },
540
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'X' as const, mimic: { motion: 'k', multiplier: -1 } }] },
541
+ { ...base, motion: [{ type: 'prismatic' as const, axis: 'X' as const, travel: { source: { of: 'part' as const, part: 'rail', axis: 'x' as const }, plus: -160 }, state: { id: 'x' } }] }
542
542
  ]
543
543
  for (const joint of joints) {
544
544
  const form = motionFormOf(joint)
545
- assert.deepEqual(motionJoint(joint, form, 'b'), joint, `${joint.type} ${JSON.stringify(joint.mimic ?? joint.travel ?? joint.lowerLimit ?? 'endless')}`)
545
+ const a = joint.motion[0]!
546
+ assert.deepEqual(motionJoint(joint, form, 'b'), joint, `${a.type} ${JSON.stringify(a.mimic ?? a.travel ?? a.lowerLimit ?? 'endless')}`)
546
547
  }
547
548
  /* A leader's stroke made to follow the base's length, where it is. */
548
549
  const changed = motionJoint(joints[0]!, { ...motionFormOf(joints[0]!), travel: 'base:z', travelPlus: -400 }, 'b')
549
- assert.deepEqual(changed.travel, { source: { of: 'part', part: 'base', axis: 'z' }, plus: -400 })
550
- assert.equal(changed.state?.id, 'extend', '같은 조작값 이름')
550
+ assert.deepEqual(changed.motion![0]!.travel, { source: { of: 'part', part: 'base', axis: 'z' }, plus: -400 })
551
+ assert.equal(changed.motion![0]!.state?.id, 'extend', '같은 조작값 이름')
551
552
  })
552
553
 
553
554
  test('바닥에 선 부품도 무엇에 붙어 있는지 읽는다 — 그래야 옮겨 붙일 수 있다 (문설주, 2026-09-25)', async () => {
@@ -693,12 +694,51 @@ test('a follower of several leaders and a stroke that is a sum of lengths are wr
693
694
  assert.equal(form.follow, '', 'no single leader to pick')
694
695
  assert.deepEqual(form.followSum, [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }])
695
696
  const saved = motionJoint(wrist, { ...form, axis: form.axis }, 'wrist')
696
- assert.deepEqual(saved.mimic, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }] }, 'saving the form keeps the follower a follower')
697
- assert.equal(saved.state, undefined, 'and gives it no control of its own')
697
+ assert.deepEqual(saved.motion![0]!.mimic, { sum: [{ motion: 'j2', multiplier: -1 }, { motion: 'j3', multiplier: -1 }] }, 'saving the form keeps the follower a follower')
698
+ assert.equal(saved.motion![0]!.state, undefined, 'and gives it no control of its own')
698
699
  assert.deepEqual(motionOf(asset, 'wrist')?.follows, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }], offset: 0 })
699
700
 
700
- const reach = { ...wrist, id: 'reach', type: 'prismatic' as const, mimic: undefined, axis: 'X' as const, travel: { sum: [{ from: { of: 'part' as const, part: 'base', axis: 'x' as const } }, { from: { of: 'part' as const, part: 'turret', axis: 'x' as const }, times: 0.5 }] } }
701
+ const reach = { ...wrist, id: 'reach', motion: [{ ...(wrist).motion![0]!, type: 'prismatic' as const, mimic: undefined, axis: 'X' as const, travel: { sum: [{ from: { of: 'part' as const, part: 'base', axis: 'x' as const } }, { from: { of: 'part' as const, part: 'turret', axis: 'x' as const }, times: 0.5 }] } }] }
701
702
  const slide = motionFormOf(reach)
702
703
  assert.equal(slide.travel, '')
703
- assert.deepEqual(motionJoint(reach, slide, 'wrist').travel, reach.travel, 'saving keeps the summed stroke')
704
+ assert.deepEqual(motionJoint(reach, slide, 'wrist').motion![0]!.travel, reach.motion[0]!.travel, 'saving keeps the summed stroke')
705
+ })
706
+
707
+ test('어깨는 축 둘 — 축 추가는 쓰지 않은 축으로 시작하고, 고쳐도 다른 축은 그대로, 같은 관절의 다른 축을 따를 수 있고, 축을 빼도 남은 축의 조작값 이름은 그대로 (ADR-0159)', async () => {
708
+ const { leadersFor, motionJoint, motionFormOf, motionOf, withoutAxis, controlIdFor } = await import('../client/modeller/links.ts')
709
+ const { compileFigureAsset, motionIdOf } = await import('@hatiolab/figure-model')
710
+ let asset = createFigureAsset('Shoulder')
711
+ asset = addFigureBox(asset, { id: 'body', size: { x: 300, y: 400, z: 200 }, position: { x: 0, y: 200, z: 0 } })
712
+ asset = addFigureBox(asset, { id: 'arm', size: { x: 60, y: 300, z: 60 }, position: { x: 0, y: 550, z: 0 } })
713
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
714
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'arm', to: { part: 'body', face: 'top' } })
715
+ const jointOf = () => asset.joints!.find(j => j.body1.part === 'arm')!
716
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf(), { ...motionFormOf(jointOf()), kind: 'turn', axis: 'x', id: 'pitch', min: -60, max: 180 }, 'arm') })
717
+
718
+ const fresh = motionFormOf(jointOf(), 1)
719
+ assert.deepEqual([fresh.kind, fresh.axis, fresh.id, fresh.min, fresh.max], ['turn', 'y', '', -90, 90], '새 축은 쓰지 않은 축의 돎')
720
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf(), fresh, 'arm', 1) })
721
+ assert.equal(jointOf().motion!.length, 2)
722
+ assert.equal(controlIdFor('arm', 'turn', 1), 'arm-turn-2')
723
+ assert.deepEqual(motionOf(asset, 'arm', 1), { kind: 'turn', axis: 'y', control: { id: 'arm-turn-2', unit: 'deg', min: -90, max: 90 } }, '둘째 축은 기록대로 읽는다')
724
+ assert.equal(motionOf(asset, 'arm')?.control?.id, 'pitch', '첫 축은 그대로')
725
+
726
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf(), { ...motionFormOf(jointOf(), 1), min: -30, max: 30 }, 'arm', 1) })
727
+ assert.deepEqual([jointOf().motion![0]!.lowerLimit, jointOf().motion![0]!.upperLimit, jointOf().motion![1]!.lowerLimit], [-60, 180, -30], '둘째 축만 바뀐다')
728
+
729
+ /* The second axis turns the arm about Y, on top of the first. */
730
+ const turned = (state: Record<string, number>) => compileFigureAsset(asset).evaluate(state).geometry.find(g => g.id === 'arm')!.pose.r
731
+ assert.notDeepEqual(turned({ 'arm-turn-2': 30 }), turned({}))
732
+
733
+ assert.deepEqual(leadersFor(asset, 'arm', 'turn', motionIdOf(jointOf(), 1)).map(a => a.id), ['arm'], '같은 관절의 첫 축은 따를 수 있다')
734
+ assert.deepEqual(leadersFor(asset, 'arm', 'turn', motionIdOf(jointOf(), 0)).map(a => a.id), ['arm.2'], '제 축은 고르지 않는다')
735
+
736
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf(), { ...motionFormOf(jointOf(), 2), id: 'roll' }, 'arm', 2) })
737
+ asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: withoutAxis(jointOf(), 0) })
738
+ assert.deepEqual(
739
+ jointOf().motion!.map(a => [a.id, a.state?.id]),
740
+ [['arm.2', 'arm-turn-2'], ['arm.3', 'roll']],
741
+ '앞 축을 빼도 뒤 축은 제 이름과 조작값을 지킨다'
742
+ )
743
+ assert.notDeepEqual(turned({ 'arm-turn-2': 30 }), turned({}), '남은 축은 같은 조작값으로 움직인다')
704
744
  })
@@ -0,0 +1,17 @@
1
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
2
+
3
+ // A relation audit fixture, not a replacement for testing the authoring workflow.
4
+ export function multipartResponseFixture() {
5
+ let a = addFigureBox(createFigureAsset('부품 관계 점검'), {
6
+ id: 'tabletop', size: { x: 1500, y: 80, z: 600 }, position: { x: 0, y: 740, z: 0 }
7
+ })
8
+ a = applyFigureAuthoring(a, { kind: 'follow-size', part: 'tabletop', axes: ['x'] })
9
+ a = addFigureBox(a, { id: 'brace', size: { x: 1300, y: 60, z: 40 }, position: { x: 0, y: 0, z: 0 } })
10
+ a = applyFigureAuthoring(a, { kind: 'link-dimension', part: 'brace', axis: 'x', source: { of: 'part', part: 'tabletop', axis: 'x' }, plus: -200 })
11
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'brace', to: { part: 'tabletop', face: 'bottom' }, align: { z: 'min' } })
12
+ for (const [id, x] of [['legLeft', 'min'], ['legRight', 'max']]) {
13
+ a = addFigureBox(a, { id, size: { x: 80, y: 700, z: 80 }, position: { x: 0, y: 0, z: 0 } })
14
+ a = applyFigureAuthoring(a, { kind: 'attach', part: id, to: { part: 'tabletop', face: 'bottom' }, align: { x, z: 'max' } })
15
+ }
16
+ return a
17
+ }
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { compileFigureAsset, applyFigureAuthoring, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { multipartResponseFixture } from './multipart-response-fixture.js'
5
+ const part = (a: ReturnType<typeof multipartResponseFixture>, width: number, id: string) =>
6
+ compileFigureAsset({ ...a, designInputs: { ...a.designInputs, 'size.x': width } }).evaluate().geometry.find(g => g.id === id)!
7
+
8
+ test('linked brace keeps a 200 mm difference while the tabletop responds to figure width', () => {
9
+ const a = multipartResponseFixture()
10
+ assert.deepEqual(figureFollowOf(a, 'brace', 'x'), { of: 'part', part: 'tabletop', axis: 'x', plus: -200 })
11
+ for (const width of [1500, 2000, 3000]) {
12
+ assert.equal(part(a, width, 'tabletop').shape.dimensions['size.x'], width)
13
+ assert.equal(part(a, width, 'brace').shape.dimensions['size.x'], width - 200)
14
+ }
15
+ })
16
+
17
+ test('fixed leg dimensions do not imply fixed position: attachment follows the tabletop edge', () => {
18
+ const a = multipartResponseFixture()
19
+ for (const id of ['legLeft', 'legRight']) {
20
+ for (const axis of ['x', 'y', 'z'] as const) assert.equal(figureFollowOf(a, id, axis).of, 'own')
21
+ assert.deepEqual(part(a, 1500, id).shape, part(a, 3000, id).shape)
22
+ const sign = id === 'legLeft' ? -1 : 1
23
+ assert.equal(part(a, 1500, id).pose.t[0], sign * 710)
24
+ assert.equal(part(a, 3000, id).pose.t[0], sign * 1460)
25
+ assert.equal(part(a, 3000, id).pose.t[1], 350)
26
+ }
27
+ })
28
+
29
+ test('a fixed difference and a proportional relation are not interchangeable', () => {
30
+ const a = multipartResponseFixture()
31
+ // Characterize the command currently invoked by the bulk button, not an endorsement of that UI.
32
+ const proportional = applyFigureAuthoring(a, { kind: 'follow-size', part: 'brace' })
33
+ assert.equal(figureFollowOf(proportional, 'brace', 'x').of, 'instance')
34
+ assert.equal(part(a, 3000, 'brace').shape.dimensions['size.x'], 2800)
35
+ assert.ok(Math.abs(part(proportional, 3000, 'brace').shape.dimensions['size.x']! - 2600) < 0.01)
36
+ assert.deepEqual(proportional.joints, a.joints, 'attachment remains while dimension reference is replaced')
37
+ })
@@ -99,9 +99,9 @@ test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그
99
99
  a = act(a, { kind: 'attach', part: 'top', to: { part: 'leg', face: 'top' } })
100
100
  a = act(a, { kind: 'attach', part: 'lamp', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
101
101
  a = act(a, { kind: 'attach', part: 'shade', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
102
- const turn = (asset: typeof a, part: string, extra: object) => act(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, type: 'revolute', axis: 'Y', ...extra } })
102
+ const turn = (asset: typeof a, part: string, extra: object) => act(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, motion: [{ type: 'revolute', axis: 'Y', ...extra }] } })
103
103
  a = turn(a, 'lamp', { state: { id: 'lamp-turn' } })
104
- a = turn(a, 'shade', { mimic: { joint: 'lamp', multiplier: 1 } })
104
+ a = turn(a, 'shade', { mimic: { motion: 'lamp', multiplier: 1 } })
105
105
 
106
106
  const top = fm.removePart(a, { kind: 'remove-part', target: 'top' })
107
107
  const told = removalNoticeOf(top.notice, keyed)!
@@ -156,7 +156,7 @@ test('a part fastened at any angle is sized by the gizmo too: its fastening keep
156
156
  const { gizmoReachOf } = await import('../client/modeller/gizmo.js')
157
157
  let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
158
158
  a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
159
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', type: 'fixed', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
159
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
160
160
  assert.ok(gizmoReachOf(a, 'blade').scale, 'offered')
161
161
  const size = [{ kind: 'set-design', input: 'blade.size.x', unit: 'mm', value: 400 }] as never
162
162
  assert.deepEqual(figureSizeEditOf(a, 'blade', size, { keep: 'opposite', axis: 'x', side: 1 }), { changes: size, kept: 'relation' })
@@ -193,3 +193,25 @@ test('the fastening form keeps a place that follows a position field, and fasten
193
193
  assert.ok('box.position.x' in again.designInputs && 'box.position.z' in again.designInputs, 'the fields stay')
194
194
  assert.deepEqual(JSON.stringify(again.joints), JSON.stringify(kept.joints), 'the same fastening')
195
195
  })
196
+
197
+ test('fresh frame: width follows without changing height, depth or pose; one undo restores the undeclared figure', async () => {
198
+ const initial = addFigureBox(createFigureAsset('Frame'), { id: 'frame', size: { x: 1500, y: 80, z: 600 }, position: { x: 0, y: 50, z: 0 } })
199
+ const source = serializeFigureAsset(initial)
200
+ const session = createFigureProductSession({ load: async () => ({ id: 'frame', source }), save: async q => ({ id: 'frame', source: q.source }) })
201
+ await session.open('frame')
202
+ const before = session.source()
203
+ session.command({ kind: 'follow-size', part: 'frame', axes: ['x'] })
204
+ const after = session.source()
205
+ const asset = parseFigureAsset(after)
206
+ assert.equal(figureFollowOf(asset, 'frame', 'x').of, 'instance')
207
+ for (const axis of ['y', 'z'] as const) assert.equal(figureFollowOf(asset, 'frame', axis).of, 'own')
208
+ const geometry = (a: typeof asset) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'frame')!
209
+ assert.deepEqual(geometry(asset), geometry(initial), 'enabling response preserves the current geometry and pose')
210
+ const enlarged = geometry({ ...asset, designInputs: { ...asset.designInputs, 'size.x': asset.designInputs['size.x']! * 2, 'size.y': asset.designInputs['size.y']! * 2, 'size.z': asset.designInputs['size.z']! * 2 } })
211
+ const expected = geometry(addFigureBox(createFigureAsset('Frame'), { id: 'frame', size: { x: 3000, y: 80, z: 600 }, position: { x: 0, y: 50, z: 0 } }))
212
+ assert.deepEqual(enlarged, expected, 'only width doubles even when every figure dimension doubles')
213
+ session.undo()
214
+ assert.equal(session.source(), before, 'one undo also removes the automatically declared figure size')
215
+ session.redo()
216
+ assert.equal(session.source(), after)
217
+ })
@@ -0,0 +1,49 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { applyFigureAuthoring, compileFigureAsset, figureRowOf, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { sizeRelationSteps, sizeRelationOf, sizeInputOf } from '../client/modeller/size-response.ts'
5
+ import type { FigureRevision } from '../client/modeller/revision.ts'
6
+ import { multipartResponseFixture } from './multipart-response-fixture.js'
7
+ const revision = (asset: ReturnType<typeof multipartResponseFixture>) => ({ asset, inputs: asset.designInputs } as FigureRevision)
8
+ const width = (a: ReturnType<typeof multipartResponseFixture>, id: string) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === id)!.shape.dimensions['size.x']
9
+
10
+ test('switching difference to ratio preserves current size, changes future response, and leaves attachment intact', () => {
11
+ const a = multipartResponseFixture()
12
+ assert.equal(sizeRelationOf(revision(a), 'brace', 'x').mode, 'difference')
13
+ const b = sizeRelationSteps(revision(a), 'brace', 'x', {of:'part',part:'tabletop',axis:'x'}, 'ratio').reduce(applyFigureAuthoring, a)
14
+ assert.equal(width(b,'brace'),1300)
15
+ assert.deepEqual(b.joints,a.joints)
16
+ assert.equal(sizeRelationOf(revision(b),'brace','x').mode,'ratio')
17
+ assert.equal(width({...b,designInputs:{...b.designInputs,'size.x':3000}},'brace'),2600)
18
+ })
19
+ test('own X can keep a difference from another part Y; switching source preserves its current size', () => {
20
+ const a = multipartResponseFixture()
21
+ const b = sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'tabletop',axis:'y'},'difference').reduce(applyFigureAuthoring,a)
22
+ assert.equal(width(b,'brace'),1300)
23
+ assert.deepEqual(figureFollowOf(b,'brace','x'),{of:'part',part:'tabletop',axis:'y',plus:1220})
24
+ assert.deepEqual(b.joints,a.joints)
25
+ })
26
+ test('editing a linked size updates its difference without changing the reference', () => {
27
+ const a=multipartResponseFixture()
28
+ const b=sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'tabletop',axis:'x'},'difference',1250.25).reduce(applyFigureAuthoring,a)
29
+ assert.equal(width(b,'brace'),1250.25)
30
+ assert.equal(width({...b,designInputs:{...b.designInputs,'size.x':3000}},'brace'),2750.25)
31
+ assert.equal(sizeInputOf(a,'brace','x'),undefined)
32
+ assert.ok(sizeInputOf(a,'legLeft','x'))
33
+ assert.throws(()=>sizeRelationSteps(revision(a),'brace','x',{of:'part',part:'brace',axis:'x'},'ratio'),/SIZE_SOURCE/)
34
+ })
35
+
36
+ test('repeat settings can change count, direction and mode without losing the attachment or size relation', () => {
37
+ let a=applyFigureAuthoring(multipartResponseFixture(), {kind:'make-repeat',part:'legLeft',axis:'x',pitch:200})
38
+ const initial=a.joints!.find(j=>j.body1.part==='legLeft')!
39
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...initial,repeat:{...initial.repeat!,count:5}}})
40
+ assert.equal(figureRowOf(a,'legLeft')!.count,5)
41
+ const current=a.joints!.find(j=>j.body1.part==='legLeft')!
42
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...current,repeat:{...current.repeat!,axis:'Z',count:3}}})
43
+ assert.equal(figureRowOf(a,'legLeft')!.count,3)
44
+ const last=a.joints!.find(j=>j.body1.part==='legLeft')!
45
+ a=applyFigureAuthoring(a,{kind:'set-joint',joint:{...last,repeat:{...last.repeat!,count:undefined,pitch:200}}})
46
+ assert.equal(a.joints!.find(j=>j.body1.part==='legLeft')!.repeat!.count,undefined)
47
+ assert.deepEqual(a.joints!.find(j=>j.body1.part==='legLeft')!.body0,initial.body0)
48
+ assert.deepEqual(figureFollowOf(a,'brace','x'),{of:'part',part:'tabletop',axis:'x',plus:-200})
49
+ })
@@ -0,0 +1,66 @@
1
+ /*
2
+ * What a size follows when it is made to follow (ADR-0159 3): what its part hangs from, along the axis its own lies
3
+ * on as it stands -- read from figure-model (`figureAxisPairsOf`), never paired by letter.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
8
+ import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
9
+
10
+ import { revisionOf } from './revision-of.ts'
11
+ import { sameSizeSource, sizeRelationOf, sizeRelationSteps, sourceMeasureOf, suggestedSizeSource } from '../client/modeller/size-response.ts'
12
+ import { measureNow } from '../client/modeller/links.ts'
13
+
14
+ /** A frame on the floor, 1000 wide, 100 high, 600 deep, and a roller on its top turned `turn` about X. */
15
+ function bed(turn = 90): FigureAsset {
16
+ let a = addFigureBox(createFigureAsset('Bed'), { id: 'frame', size: { x: 1000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
17
+ a = addFigurePrimitive(a, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 500 }, rotation: { x: turn, y: 0, z: 0 }, position: { x: 0, y: 150, z: 0 } } as never)
18
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
19
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'roller', to: { part: 'frame', face: 'top' } })
20
+ }
21
+ const run = (a: FigureAsset, steps: FigureAuthoringAction[]) => steps.reduce((x, s) => applyFigureAuthoring(x, s), a)
22
+ const lengthOf = (a: FigureAsset, design: Record<string, number> = {}) =>
23
+ measureNow(revisionOf({ ...a, designInputs: { ...a.designInputs, ...design } }), 'roller', 'y')
24
+ /** The frame's depth: its own size along Z. */
25
+ const depthOf = (_: FigureAsset) => 'frame.size.z'
26
+
27
+ test('a roller laid on its side follows the frame\'s depth with its length, not its height', () => {
28
+ assert.deepEqual(suggestedSizeSource(bed(), 'roller', 'y'), { of: 'part', part: 'frame', axis: 'z' })
29
+ /* Its diameter lies along the frame's width. */
30
+ assert.deepEqual(suggestedSizeSource(bed(), 'roller', 'x'), { of: 'part', part: 'frame', axis: 'x' })
31
+ })
32
+
33
+ test('made to follow by its gap, the length keeps 100 mm short of the frame\'s depth as the frame grows', () => {
34
+ const a = bed()
35
+ const b = run(a, sizeRelationSteps(revisionOf(a), 'roller', 'y', suggestedSizeSource(a, 'roller', 'y')!, 'difference'))
36
+ assert.equal(lengthOf(b), 500, 'it stands as it stood')
37
+ assert.ok(depthOf(b) in b.designInputs, `the frame's depth is an input: ${Object.keys(b.designInputs)}`)
38
+ assert.equal(lengthOf(b, { [depthOf(b)]: 900 }), 800, 'the frame 300 deeper: the roller 300 longer')
39
+ assert.equal(sizeRelationOf(revisionOf(b), 'roller', 'y').mode, 'difference')
40
+ })
41
+
42
+ test('made to follow by its ratio, it keeps its share', () => {
43
+ const a = bed()
44
+ const b = run(a, sizeRelationSteps(revisionOf(a), 'roller', 'y', suggestedSizeSource(a, 'roller', 'y')!, 'ratio'))
45
+ assert.equal(Math.round(lengthOf(b, { [depthOf(b)]: 1200 })!), 1000, '500 of 600 is 1000 of 1200')
46
+ })
47
+
48
+ test('standing on the floor turned a quarter, a part follows the figure along the axis it lies on', () => {
49
+ let a = addFigureBox(createFigureAsset('Beam'), { id: 'beam', size: { x: 100, y: 50, z: 800 }, position: { x: 0, y: 25, z: 0 }, rotation: { x: 0, y: 90, z: 0 } } as never)
50
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'beam', to: { plane: 'mounting-plane' } })
51
+ assert.deepEqual(suggestedSizeSource(a, 'beam', 'z'), { of: 'instance', axis: 'x' })
52
+ })
53
+
54
+ test('at an angle, nothing is suggested for the axes that lie at it: the author picks', () => {
55
+ assert.equal(suggestedSizeSource(bed(30), 'roller', 'y'), undefined)
56
+ assert.deepEqual(suggestedSizeSource(bed(30), 'roller', 'x'), { of: 'part', part: 'frame', axis: 'x' }, 'the axis it turns about still lies along X')
57
+ })
58
+
59
+ test('the source as it measures now, and when two sources are one', () => {
60
+ const r = revisionOf(bed())
61
+ assert.equal(sourceMeasureOf(r, { of: 'part', part: 'frame', axis: 'z' }), 600)
62
+ assert.ok(sameSizeSource({ of: 'part', part: 'frame', axis: 'z' }, { of: 'part', part: 'frame', axis: 'z' }))
63
+ assert.ok(!sameSizeSource({ of: 'part', part: 'frame', axis: 'z' }, { of: 'part', part: 'frame', axis: 'y' }))
64
+ assert.ok(!sameSizeSource({ of: 'instance', axis: 'z' }, { of: 'part', part: 'frame', axis: 'z' }))
65
+ assert.ok(!sameSizeSource(undefined, { of: 'instance', axis: 'z' }))
66
+ })
@@ -52,9 +52,9 @@
52
52
  "figure.text.scale-position-explained": "keeps this place as a share of the figure size — at twice the size it is twice as far.",
53
53
  "figure.text.repeat-member": "copy {index} of {count} made by repeating {name}.",
54
54
  "figure.label.detail-none": "none",
55
- "figure.label.repeat": "arranged in a row",
56
- "figure.text.repeat-fits-length": "as many copies as fit the figure’s length at this pitch.",
57
- "figure.text.repeat-fixed-count": "a repeat of a fixed count; its pitch cannot be set yet.",
55
+ "figure.label.repeat": "Repeat layout",
56
+ "figure.text.repeat-fits-length": "Parts fill the parent length at the chosen centre spacing.",
57
+ "figure.text.repeat-fixed-count": "Centre spacing is calculated automatically to keep the count.",
58
58
  "figure.text.repeat-what-pitch-gives": "{count} at a length of {length} mm · placed up to {longest} mm long ({limit} in a row).",
59
59
  "figure.text.repeat-pitch-too-small": "the pitch must be at least {min} mm: at the authored length of {length} mm it would make more than {limit}.",
60
60
  "figure.text.row-copies-over-limit": "At this pitch the row has {count} copies. A row takes up to {limit}. Widen the pitch.",
@@ -76,6 +76,7 @@
76
76
  "figure.text.gizmo-joint-turned-slide": "the {joint} joint only slides. use the move handle.",
77
77
  "figure.text.gizmo-joint-origin-follows": "the {joint} joint's place follows another part. change it under place in the properties tab.",
78
78
  "figure.text.gizmo-joint-off-axis": "the {joint} joint turns only about {axis}. use the {axis} rotate handle.",
79
+ "figure.text.gizmo-joint-several-axes": "the {joint} joint moves on several axes, so it cannot be dragged. adjust its values on the motion tab.",
79
80
  "figure.text.gizmo-size-follows": "{part}'s {axis} size follows another measure. change it under size in the responsive tab.",
80
81
  "figure.text.gizmo-fastening-places": "{part} is fastened to a face of another part, which places it. detach it first to move it.",
81
82
  "figure.text.size-range-leaves-out-size": "the {axis} range on a drawing, {range} mm, does not hold its size now, {at} mm.",
@@ -593,6 +594,9 @@
593
594
  "figure.label.control-start": "start",
594
595
  "figure.label.travel-follows": "travel follows",
595
596
  "figure.button.add-motion": "Add motion",
597
+ "figure.button.add-axis": "Add axis",
598
+ "figure.button.remove-axis": "Remove axis",
599
+ "figure.label.motion-axis-n": "Axis {n}",
596
600
  "figure.button.remove-motion": "Remove motion",
597
601
  "figure.label.room": "Room it takes up",
598
602
  "figure.text.room-explained": "Bounds computed from the parts are a proposal. Nothing is declared until you confirm, and a declaration never widens on its own.",
@@ -697,12 +701,12 @@
697
701
  "figure.label.face-opening-left": "opening left",
698
702
  "figure.label.face-opening-right": "opening right",
699
703
  "figure.label.face-opening-top": "opening top",
700
- "figure.label.repeat-fills": "as many as fill the length (set the pitch)",
701
- "figure.button.make-repeat": "arrange in a row",
702
- "figure.button.remove-repeat": "remove the row",
703
- "figure.label.row-basis": "how many",
704
- "figure.label.row-by-count": "a set count",
705
- "figure.text.row-count-spreads": "A set count is spread evenly over the length. Check the spacing in the preview.",
704
+ "figure.label.repeat-fills": "Keep spacing",
705
+ "figure.button.make-repeat": "Apply repeat layout",
706
+ "figure.button.remove-repeat": "Remove repeat layout",
707
+ "figure.label.row-basis": "Layout rule",
708
+ "figure.label.row-by-count": "Keep count",
709
+ "figure.text.row-count-spreads": "Distribute the chosen count evenly along the parent length. Centre spacing is calculated automatically.",
706
710
  "figure.text.row-blocked-unfastened": "Fasten {part} to another part to arrange it in a row along that part.",
707
711
  "figure.text.row-blocked-on-plane": "Fasten it to another part, not the mounting plane, to arrange it in a row along that part.",
708
712
  "figure.text.row-blocked-at-origin": "It stands at another part's origin. Fasten it to a face of that part to arrange it in a row along it.",
@@ -1184,5 +1188,38 @@
1184
1188
  "figure.text.board-size-range-explained": "A drawing can resize it within these ranges. An empty field is as far as the parts allow; a number typed narrows it to that. Only the parts that follow its size grow with it; the rest stay as they are.",
1185
1189
  "figure.text.board-size-followers": "parts that change with its size: {parts}",
1186
1190
  "figure.text.board-size-followers-more": "{first} and {rest} more",
1187
- "figure.text.board-size-no-followers": "no part changes with its size"
1191
+ "figure.text.board-size-no-followers": "no part changes with its size",
1192
+ "figure.button.resize-follow": "Scale proportionally",
1193
+ "figure.button.resize-follow-axis": "Scale {dimension} proportionally",
1194
+ "figure.text.resize-follow-axis": "Follow the figure size at the current proportion. Other dimensions stay unchanged.",
1195
+ "figure.label.size-axis": "axis",
1196
+ "figure.label.size-measure": "size (mm)",
1197
+ "figure.label.size-follows": "follows",
1198
+ "figure.label.size-source": "source",
1199
+ "figure.label.size-source-axis": "source axis",
1200
+ "figure.label.size-diameter": "diameter",
1201
+ "figure.label.size-figure": "figure",
1202
+ "figure.label.size-ratio": "keep the ratio",
1203
+ "figure.label.size-difference": "keep the gap",
1204
+ "figure.label.size-existing-rule": "existing formula",
1205
+ "figure.label.size-pick-source": "pick a source",
1206
+ "figure.button.size-other-source": "another source",
1207
+ "figure.text.size-ratio-result": "{pct}% of {source} {size} mm — changes by the same ratio when the source does.",
1208
+ "figure.text.size-difference-less": "{gap} mm less than {source} {size} mm — keeps that gap when the source changes.",
1209
+ "figure.text.size-difference-more": "{gap} mm more than {source} {size} mm — keeps that gap when the source changes.",
1210
+ "figure.text.size-difference-same": "the same as {source} {size} mm — changes as the source does.",
1211
+ "figure.text.size-custom-rule": "set by an existing formula. pick a source or a way and it changes from the size it is now.",
1212
+ "figure.text.size-uniform": "the whole figure grows and shrinks by one ratio.",
1213
+ "figure.text.size-invalid": "enter a size above 0.",
1214
+ "figure.text.size-unavailable-source": "this source's size cannot be followed.",
1215
+ "figure.text.size-unpaired": "it lies at an angle, so the axis to follow cannot be told. pick a source and an axis.",
1216
+ "figure.label.repeat-inactive": "Not applied",
1217
+ "figure.label.repeat-active": "Applied",
1218
+ "figure.label.tab-arrangement": "Layout",
1219
+ "figure.text.repeat-intro": "Repeat this part along the length of the part it is attached to.",
1220
+ "figure.text.repeat-direction": "Direction uses the attached parent’s axes.",
1221
+ "figure.text.repeat-draft": "Preview only. Apply to confirm this layout.",
1222
+ "figure.text.repeat-mode-pitch": "Keep spacing · Count changes with the parent length.",
1223
+ "figure.text.repeat-mode-count": "Keep count · Spacing changes with the parent length.",
1224
+ "figure.text.repeat-remove-explained": "Removing the layout leaves the original part."
1188
1225
  }
@@ -52,9 +52,9 @@
52
52
  "figure.text.scale-position-explained": "mantiene esta posición como fracción del tamaño de la figura: con el doble de tamaño queda al doble de distancia.",
53
53
  "figure.text.repeat-member": "copia {index} de {count} creada al repetir {name}.",
54
54
  "figure.label.detail-none": "ninguno",
55
- "figure.label.repeat": "dispuestas en fila",
56
- "figure.text.repeat-fits-length": "tantas copias como quepan en la longitud de la figura con este pitch.",
57
- "figure.text.repeat-fixed-count": "una repetición de cantidad fija; su pitch aún no se puede definir.",
55
+ "figure.label.repeat": "Distribución repetida",
56
+ "figure.text.repeat-fits-length": "Las piezas ocupan la longitud de referencia con la separación elegida.",
57
+ "figure.text.repeat-fixed-count": "La separación se calcula para mantener la cantidad.",
58
58
  "figure.text.repeat-what-pitch-gives": "{count} con una longitud de {length} mm · se colocan hasta {longest} mm de longitud ({limit} en una fila).",
59
59
  "figure.text.repeat-pitch-too-small": "el pitch debe ser de al menos {min} mm: con la longitud definida de {length} mm se superarían {limit}.",
60
60
  "figure.text.row-copies-over-limit": "Con este paso la fila tiene {count} copias. Una fila admite hasta {limit}. Amplíe el paso.",
@@ -76,6 +76,7 @@
76
76
  "figure.text.gizmo-joint-turned-slide": "la articulación {joint} solo se desliza. use el control de desplazamiento.",
77
77
  "figure.text.gizmo-joint-origin-follows": "la posición de la articulación {joint} sigue a otra pieza. cámbiela en sujeción, en la pestaña de movimiento.",
78
78
  "figure.text.gizmo-joint-off-axis": "la articulación {joint} solo gira alrededor de {axis}. use el control de rotación de {axis}.",
79
+ "figure.text.gizmo-joint-several-axes": "la articulación {joint} se mueve en varios ejes, así que no se puede arrastrar. ajuste sus valores en la pestaña de movimiento.",
79
80
  "figure.text.gizmo-size-follows": "el tamaño en {axis} de {part} sigue a otra medida. cámbielo en tamaños, en la pestaña de movimiento.",
80
81
  "figure.text.gizmo-fastening-places": "{part} está sujeta a una cara de otra pieza, que determina su posición. sepárela primero para moverla.",
81
82
  "figure.text.size-range-leaves-out-size": "el rango {axis} en un plano, {range} mm, no incluye su tamaño actual, {at} mm.",
@@ -593,6 +594,9 @@
593
594
  "figure.label.control-start": "inicio",
594
595
  "figure.label.travel-follows": "el recorrido sigue a",
595
596
  "figure.button.add-motion": "Añadir movimiento",
597
+ "figure.button.add-axis": "Añadir eje",
598
+ "figure.button.remove-axis": "Quitar eje",
599
+ "figure.label.motion-axis-n": "Eje {n}",
596
600
  "figure.button.remove-motion": "Quitar movimiento",
597
601
  "figure.label.room": "Espacio que ocupa",
598
602
  "figure.text.room-explained": "Los límites calculados a partir de las piezas son una propuesta. No se declara nada hasta que lo confirme, y una declaración nunca se amplía por sí sola.",
@@ -697,12 +701,12 @@
697
701
  "figure.label.face-opening-left": "abertura izquierda",
698
702
  "figure.label.face-opening-right": "abertura derecha",
699
703
  "figure.label.face-opening-top": "abertura superior",
700
- "figure.label.repeat-fills": "tantas como llenen la longitud (definir el pitch)",
701
- "figure.button.make-repeat": "disponer en fila",
702
- "figure.button.remove-repeat": "quitar la fila",
703
- "figure.label.row-basis": "cuántas",
704
- "figure.label.row-by-count": "una cantidad fija",
705
- "figure.text.row-count-spreads": "Una cantidad fija se reparte uniformemente a lo largo de la longitud. Compruebe la separación en la vista previa.",
704
+ "figure.label.repeat-fills": "Mantener separación",
705
+ "figure.button.make-repeat": "Aplicar distribución",
706
+ "figure.button.remove-repeat": "Quitar distribución",
707
+ "figure.label.row-basis": "Regla de distribución",
708
+ "figure.label.row-by-count": "Mantener cantidad",
709
+ "figure.text.row-count-spreads": "Distribuya la cantidad elegida uniformemente. La separación se calcula automáticamente.",
706
710
  "figure.text.row-blocked-unfastened": "Sujete {part} a otra pieza para disponerla en fila a lo largo de esa pieza.",
707
711
  "figure.text.row-blocked-on-plane": "Sujétela a otra pieza, no al plano de montaje, para disponerla en fila a lo largo de esa pieza.",
708
712
  "figure.text.row-blocked-at-origin": "Está en el origen de otra pieza. Sujétela a una cara de esa pieza para disponerla en fila a lo largo de ella.",
@@ -1184,5 +1188,38 @@
1184
1188
  "figure.text.board-size-range-explained": "Un plano puede cambiar su tamaño dentro de estos rangos. Un campo vacío llega hasta donde permiten las piezas; un número lo limita a ese valor. Solo crecen las piezas que siguen su tamaño; las demás quedan igual.",
1185
1189
  "figure.text.board-size-followers": "piezas que cambian con su tamaño: {parts}",
1186
1190
  "figure.text.board-size-followers-more": "{first} y {rest} más",
1187
- "figure.text.board-size-no-followers": "ninguna pieza cambia con su tamaño"
1191
+ "figure.text.board-size-no-followers": "ninguna pieza cambia con su tamaño",
1192
+ "figure.button.resize-follow": "Cambiar proporcionalmente",
1193
+ "figure.button.resize-follow-axis": "Cambiar {dimension} proporcionalmente",
1194
+ "figure.text.resize-follow-axis": "Seguir el tamaño de la figura manteniendo la proporción actual. Las otras dimensiones no cambian.",
1195
+ "figure.label.size-axis": "eje",
1196
+ "figure.label.size-measure": "tamaño (mm)",
1197
+ "figure.label.size-follows": "sigue",
1198
+ "figure.label.size-source": "referencia",
1199
+ "figure.label.size-source-axis": "eje de referencia",
1200
+ "figure.label.size-diameter": "diámetro",
1201
+ "figure.label.size-figure": "figura",
1202
+ "figure.label.size-ratio": "mantener la proporción",
1203
+ "figure.label.size-difference": "mantener la holgura",
1204
+ "figure.label.size-existing-rule": "fórmula existente",
1205
+ "figure.label.size-pick-source": "elija una referencia",
1206
+ "figure.button.size-other-source": "otra referencia",
1207
+ "figure.text.size-ratio-result": "{pct}% de {source} {size} mm — cambia en la misma proporción cuando cambia la referencia.",
1208
+ "figure.text.size-difference-less": "{gap} mm menos que {source} {size} mm — mantiene esa diferencia cuando cambia la referencia.",
1209
+ "figure.text.size-difference-more": "{gap} mm más que {source} {size} mm — mantiene esa diferencia cuando cambia la referencia.",
1210
+ "figure.text.size-difference-same": "igual que {source} {size} mm — cambia como la referencia.",
1211
+ "figure.text.size-custom-rule": "lo fija una fórmula existente. elija una referencia o un modo y cambiará a partir del tamaño actual.",
1212
+ "figure.text.size-uniform": "toda la figura crece y se reduce en una sola proporción.",
1213
+ "figure.text.size-invalid": "introduzca un tamaño mayor que 0.",
1214
+ "figure.text.size-unavailable-source": "no se puede seguir el tamaño de esta referencia.",
1215
+ "figure.text.size-unpaired": "está en ángulo, así que no se sabe qué eje seguir. elija una referencia y un eje.",
1216
+ "figure.label.repeat-inactive": "Sin aplicar",
1217
+ "figure.label.repeat-active": "Aplicada",
1218
+ "figure.label.tab-arrangement": "Distribución",
1219
+ "figure.text.repeat-intro": "Repita esta pieza a lo largo de la pieza a la que está unida.",
1220
+ "figure.text.repeat-direction": "La dirección usa los ejes de la pieza de unión.",
1221
+ "figure.text.repeat-draft": "Vista previa. Pulse Aplicar para confirmar.",
1222
+ "figure.text.repeat-mode-pitch": "Mantener separación · La cantidad cambia con la longitud de referencia.",
1223
+ "figure.text.repeat-mode-count": "Mantener cantidad · La separación cambia con la longitud de referencia.",
1224
+ "figure.text.repeat-remove-explained": "Al quitar la distribución queda la pieza original."
1188
1225
  }