@things-factory/figure-ui 10.1.45 → 10.1.47

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 (138) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +150 -79
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +221 -263
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +532 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +126 -10
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +10 -2
  45. package/dist-client/modeller/figure-v3-links.js +134 -79
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +556 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +44 -2
  90. package/dist-client/modeller/v3-links.js +125 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +118 -7
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +98 -5
  135. package/translations/ja.json +100 -7
  136. package/translations/ko.json +98 -5
  137. package/translations/ms.json +100 -7
  138. package/translations/zh.json +100 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.45",
3
+ "version": "10.1.47",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,19 +26,19 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.47",
30
- "@hatiolab/things-scene": "^10.1.26",
29
+ "@hatiolab/figure-model": "^0.1.53",
30
+ "@hatiolab/things-scene": "^10.1.30",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
34
34
  "@operato/i18n": "^10.0.0",
35
- "@operato/layout": "^10.0.0",
35
+ "@operato/layout": "^10.28.0",
36
36
  "@operato/p13n": "^10.0.0",
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
39
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.45",
40
+ "@things-factory/figure-service": "^10.1.47",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "d72e1fd1285797fcd7839d97a18e31f5bf076ae3"
43
+ "gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
44
44
  }
@@ -0,0 +1,70 @@
1
+ /*
2
+ * **클립 — V2 의 애니메이션** (V3 동등표 ⑥, 2026-09-24).
3
+ *
4
+ * 조작값을 시간에 따라 움직이는 자리다. 읽는 값은 figure-model 편집을 실제로 적용한 자산에서 재고, 배선은 소스에서 본다.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ import { addV3Box, applyV3Authoring, createV3Asset, editV3Part } from '@hatiolab/figure-model'
12
+ import type { V3Asset } from '@hatiolab/figure-model'
13
+
14
+ import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey } from '../client/modeller/v3-clips.ts'
15
+
16
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
+
18
+ const machine = (): V3Asset => {
19
+ let asset = createV3Asset('Machine')
20
+ asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
21
+ asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60, start: 0 } })
22
+ asset = addV3Box(asset, { id: 'arm', size: { x: 40, y: 200, z: 40 }, position: { x: 0, y: 200, z: 0 } })
23
+ return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'spin', unit: 'deg', min: -180, max: 180, start: 0 } })
24
+ }
25
+
26
+ test('클립마다 길이와 트랙을, 트랙마다 키를 초로 읽는다', () => {
27
+ let asset = editV3Part(machine(), { kind: 'set-state-input', input: 'shift', label: '밀기' })
28
+ asset = editV3Part(asset, { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
29
+ assert.deepEqual(undrivenOf(asset), ['spin'], '재생하지 않는 조작값만 새 트랙이 된다')
30
+ const [clip] = clipsOf(asset)
31
+ assert.equal(clip!.name, 'cycle')
32
+ assert.equal(clip!.duration, 4)
33
+ assert.deepEqual(clip!.tracks.map(t => [t.state, t.label, t.unit, t.keys]), [['shift', '밀기', 'mm', [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }]]])
34
+ })
35
+
36
+ test('막대를 옮기면 그 순간의 자세를 한 묶음으로 — 드라이버와 같은 값', () => {
37
+ const asset = editV3Part(machine(), { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
38
+ assert.deepEqual(poseAt(asset, 'cycle', 1), [{ kind: 'set-state', input: 'shift', unit: 'mm', value: 15 }])
39
+ assert.deepEqual(poseAt(asset, 'nothing', 1), [])
40
+ })
41
+
42
+ test('키를 그 순간에 더하고 빼며, 쓰면 figure-model 이 받는다 — 양 끝은 남는다', () => {
43
+ let asset = editV3Part(machine(), { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
44
+ const [clip] = clipsOf(asset)
45
+ const track = clip!.tracks[0]!
46
+ const keys = withKey(track.keys, { seconds: 2, value: 30 })
47
+ assert.deepEqual(keys.map(k => k.seconds), [0, 2, 4])
48
+ assert.deepEqual(withKey(keys, { seconds: 2, value: 45 }).map(k => k.value), [0, 45, 60], '같은 순간이면 바꾼다')
49
+ asset = editV3Part(asset, trackEdit(clip!, track, { keys }))
50
+ assert.equal(clipsOf(asset)[0]!.tracks[0]!.keys.length, 3)
51
+ assert.equal(withoutKey(track.keys, 1), null, '두 개뿐이면 뺄 수 없다')
52
+ assert.equal(withoutKey(keys, 0), null, '처음 키(0초)는 뺄 수 없다')
53
+ assert.deepEqual(withoutKey(keys, 1)!.map(k => k.seconds), [0, 4])
54
+ })
55
+
56
+ test('움직임 탭에 서고, 막대는 입력 한 묶음 · 나머지는 부품 편집으로 편집기에 닿는다', () => {
57
+ const editor = read('../client/modeller/v3-editor.ts')
58
+ const start = editor.indexOf('<figure-v3-clips')
59
+ const block = editor.slice(start, editor.indexOf('</figure-v3-clips>', start))
60
+ assert.ok(start >= 0)
61
+ assert.ok(block.includes('@v3-states=${(e: CustomEvent) => this.run(() => this.session!.apply(e.detail.changes))}'))
62
+ assert.ok(block.includes('@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}'))
63
+ const clips = read('../client/modeller/figure-v3-clips.ts')
64
+ assert.ok(clips.includes("@change=${(e: Event) => this.scrub(clip, number(e))}"), '막대는 놓을 때 한 번')
65
+ const keys = [...clips.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
66
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
67
+ const table = JSON.parse(read(`../translations/${lang}.json`))
68
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
69
+ }
70
+ })
@@ -0,0 +1,68 @@
1
+ /*
2
+ * **조작값 — V2 의 파라미터와 자세 도구** (V3 동등표 ⑥, 2026-09-24).
3
+ *
4
+ * 보드가 값을 바꿔 도형을 움직이는 자리다. 화면이 읽는 값을 명령을 실제로 적용한 자산에서 재고, 화면의 배선은
5
+ * 소스에서 확인한다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ import { addV3Box, applyV3Authoring, createV3Asset, editV3Part } from '@hatiolab/figure-model'
13
+ import type { V3Asset } from '@hatiolab/figure-model'
14
+
15
+ import { controlsOf, poseAsDefault, poseDiffers } from '../client/modeller/v3-controls.ts'
16
+
17
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
18
+
19
+ const slider = (): V3Asset => {
20
+ let asset = createV3Asset('Slider')
21
+ asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
22
+ asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60, start: 20 } })
23
+ asset = addV3Box(asset, { id: 'arm', size: { x: 40, y: 200, z: 40 }, position: { x: 0, y: 200, z: 0 } })
24
+ return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'spin', unit: 'deg', min: -90, max: 90, start: 0 } })
25
+ }
26
+
27
+ test('조작값마다 이름 · 단위 · 범위 · 지금 값 · 시작 값 · 이동 시간을 읽는다', () => {
28
+ let asset = slider()
29
+ asset = editV3Part(asset, { kind: 'set-state-input', input: 'shift', label: '밀기', sweep: 2 })
30
+ const [shift, spin] = controlsOf(asset, { shift: 45 })
31
+ assert.deepEqual(
32
+ { ...shift, step: undefined },
33
+ { id: 'shift', label: '밀기', named: true, unit: 'mm', min: 0, max: 60, value: 45, start: 20, sweep: 2, step: undefined }
34
+ )
35
+ assert.equal(shift!.step, 0.6, '길이는 범위의 백분의 일씩')
36
+ assert.equal(spin!.label, 'spin', '이름이 없으면 id')
37
+ assert.equal(spin!.named, false)
38
+ assert.equal(spin!.step, 1, '각도는 1도씩')
39
+ assert.equal(spin!.value, 0, '편집기 값이 없으면 시작 값')
40
+ })
41
+
42
+ test('지금 자세를 기본으로 — 보고 있는 자세가 시작 자세와 다를 때만, 한 번의 편집으로', () => {
43
+ const asset = slider()
44
+ assert.equal(poseDiffers(controlsOf(asset)), false)
45
+ const looking = controlsOf(asset, { shift: 50, spin: 30 })
46
+ assert.equal(poseDiffers(looking), true)
47
+ const kept = editV3Part(asset, poseAsDefault(looking))
48
+ assert.deepEqual([kept.stateDefaults['shift'], kept.stateDefaults['spin']], [50, 30])
49
+ assert.equal(poseDiffers(controlsOf(kept, { shift: 50, spin: 30 })), false)
50
+ })
51
+
52
+ test('편집기에 닿는다 — 값 바꾸기는 입력으로, 이름 · 범위 · 시간 · 기본 자세는 부품 편집으로', () => {
53
+ const editor = read('../client/modeller/v3-editor.ts')
54
+ const start = editor.indexOf('<figure-v3-controls')
55
+ const block = editor.slice(start, editor.indexOf('</figure-v3-controls>', start))
56
+ assert.ok(start >= 0)
57
+ assert.ok(block.includes("@v3-state=${(e: CustomEvent) => this.run(() => this.session!.apply([{ kind: 'set-state'"))
58
+ assert.ok(block.includes('@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}'))
59
+ assert.ok(editor.includes("const design = view.fields.filter(i => i.role !== 'state')"), '조작값은 설계 값 칸에 두 번 나오지 않는다')
60
+
61
+ const controls = read('../client/modeller/figure-v3-controls.ts')
62
+ assert.ok(controls.includes("@change=${(e: Event) => this.try(c, number(e))}"), '슬라이더는 놓을 때 한 번 — 끄는 동안 기록이 쌓이지 않는다')
63
+ const keys = [...controls.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
64
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
65
+ const table = JSON.parse(read(`../translations/${lang}.json`))
66
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
67
+ }
68
+ })
@@ -0,0 +1,62 @@
1
+ /*
2
+ * **값 칸의 묶음 — V2 인스펙터처럼** (사용자 2026-09-24: 「적절한 그루핑이 없어서 가시성이 좋지 않다. 크기는 왜
3
+ * 한줄씩 차지하는가」). 위치 · 회전 · 크기는 X · Y · Z 세 칸 한 줄, 형상의 나머지 치수는 제 제목 아래 한 줄씩.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import { groupFields, partLabelOf } from '../client/modeller/v3-fields.ts'
11
+
12
+ const f = (id: string, value = 1, unit = 'mm') => ({ id, unit, value, role: 'design' })
13
+
14
+ test('부품의 위치 · 회전 · 크기는 세 칸 한 줄씩, 나머지는 한 줄씩', () => {
15
+ const fields = [
16
+ f('arm.position.x'), f('arm.position.y'), f('arm.position.z'),
17
+ f('arm.rotation.x', 0, 'deg'), f('arm.rotation.y', 0, 'deg'), f('arm.rotation.z', 0, 'deg'),
18
+ f('arm.dimension.radius'), f('arm.dimension.length'),
19
+ f('other.position.x'), f('size.x')
20
+ ]
21
+ const { vectors, singles } = groupFields(fields, 'arm')
22
+ assert.deepEqual(vectors.map(g => g.kind === 'vector' && g.name), ['position', 'rotation'])
23
+ assert.deepEqual(singles.map(g => g.kind === 'single' && g.name), ['radius', 'length'], '다른 부품 · 도형의 값은 섞이지 않는다')
24
+ })
25
+
26
+ test('도형은 크기(size.x · y · z)가 세 칸 한 줄', () => {
27
+ const { vectors, singles } = groupFields([f('size.x'), f('size.y'), f('size.z'), f('opening'), f('arm.position.x')])
28
+ assert.deepEqual(vectors.map(g => g.kind === 'vector' && g.name), ['size'])
29
+ assert.deepEqual(singles.map(g => g.kind === 'single' && g.name), ['opening'])
30
+ })
31
+
32
+ test('오른쪽 칸은 한 벌의 규격 — 요소마다 제 제목 · 칸 · 단추 규칙을 두지 않는다', () => {
33
+ for (const file of ['v3-editor', 'figure-v3-properties', 'figure-v3-links', 'figure-v3-material', 'figure-v3-controls', 'figure-v3-clips', 'figure-v3-capability']) {
34
+ const source = readFileSync(fileURLToPath(new URL(`../client/modeller/${file}.ts`, import.meta.url)), 'utf8')
35
+ assert.ok(source.includes('SidePanelStyles'), `${file} 가 공용 규격을 쓴다`)
36
+ const styles = source.slice(source.indexOf('static styles'), source.indexOf('`]', source.indexOf('static styles')))
37
+ assert.doesNotMatch(styles, /\n h3 \{|\n button\[act\] \{|\n div\[form\] \{/, `${file} 가 제 규칙을 다시 적는다`)
38
+ }
39
+ })
40
+
41
+ test('긴 설명은 제목 옆 「?」 로 연다 — 늘 펼쳐 두지 않는다 (V2)', () => {
42
+ for (const [file, key] of [
43
+ ['figure-v3-links', 'figure.text.motion-explained'],
44
+ ['figure-v3-links', 'figure.text.place-explained'],
45
+ ['figure-v3-links', 'figure.text.fastening-explained'],
46
+ ['figure-v3-clips', 'figure.text.clips-explained']
47
+ ]) {
48
+ const source = readFileSync(fileURLToPath(new URL(`../client/modeller/${file}.ts`, import.meta.url)), 'utf8')
49
+ assert.ok(source.includes(`this.help.button('${key}')`) && source.includes(`this.help.hint('${key}')`), `${file}: ${key}`)
50
+ assert.ok(!source.includes(`<p hint>\${i18next.t('${key}')}</p>`), `${file}: ${key} 가 늘 펼쳐져 있다`)
51
+ }
52
+ })
53
+
54
+ test('되풀이한 부품은 되풀이의 이름과 몇 번째인지로 읽는다 — 날 id 를 보이지 않는다', () => {
55
+ assert.equal(partLabelOf('["roller.repeat",0,"roller.value"]'), 'roller 1')
56
+ assert.equal(partLabelOf('["rack.repeat",1,"shelf.repeat",2,"shelf.value"]'), 'rack 2·3')
57
+ assert.equal(partLabelOf('deck'), 'deck', '보통 부품은 제 이름')
58
+ assert.equal(partLabelOf('[not json'), '[not json')
59
+ const parts = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-parts.ts', import.meta.url)), 'utf8')
60
+ assert.ok(parts.includes('>${partLabelOf(part.id)}'), '목록이 이 이름으로 그린다')
61
+ })
62
+
@@ -81,12 +81,10 @@ test('끈 것은 세션의 한 걸음이다 — 한 번의 되돌리기로 돌
81
81
  assert.match(editor, /if \(changes\.length\) this\.run\(\(\) => this\.session!\.apply\(changes\)\)/)
82
82
  })
83
83
 
84
- test('캔버스 — W · E · R, 끄는 동안 궤도 조작을 멈추고, 놓는 순간은 고르기가 아니다', () => {
84
+ test('캔버스 — 기즈모 · W E R · 스냅은 씬의 편집 모드가 한다, V2 처럼', () => {
85
85
  const canvas = read('../client/modeller/v3-canvas.ts')
86
- assert.match(canvas, /\{ w: 'translate', e: 'rotate', r: 'scale' \}/)
87
- assert.match(canvas, /this\.controls\.enabled = !on/)
88
- assert.match(canvas, /if \(this\.justDragged \|\| this\.transform\?\.dragging\) return/)
89
- assert.match(canvas, /setFromQuaternion\(this\.proxy\.quaternion, 'XYZ'\)/)
86
+ assert.match(canvas, /mode: 1\n/)
87
+ assert.doesNotMatch(canvas, /TransformControls|OrbitControls/)
90
88
  })
91
89
 
92
90
  test('기즈모의 말은 다섯 말 모두에 있다', () => {
@@ -0,0 +1,37 @@
1
+ /*
2
+ * **모델러의 키** — V2 의 Ctrl+Z · Ctrl+Y 에 더해 복제 · 지우기 · 도형 전체로. 칸에 치는 키는 칸의 것이다.
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ import { keyActionOf } from '../client/modeller/v3-keys.ts'
10
+
11
+ test('되돌리기 · 다시 하기 — Ctrl 과 Cmd 모두, 부품을 고르지 않아도', () => {
12
+ assert.equal(keyActionOf({ key: 'z', ctrlKey: true }, ''), 'undo')
13
+ assert.equal(keyActionOf({ key: 'Z', metaKey: true, shiftKey: true }, ''), 'redo')
14
+ assert.equal(keyActionOf({ key: 'y', ctrlKey: true }, ''), 'redo')
15
+ })
16
+
17
+ test('고른 부품에만 — 복제 · 지우기 · 도형 전체로', () => {
18
+ assert.equal(keyActionOf({ key: 'd', ctrlKey: true }, 'arm'), 'duplicate')
19
+ assert.equal(keyActionOf({ key: 'Delete' }, 'arm'), 'remove')
20
+ assert.equal(keyActionOf({ key: 'Backspace' }, 'arm'), 'remove')
21
+ assert.equal(keyActionOf({ key: 'Escape' }, 'arm'), 'deselect')
22
+ for (const key of ['Delete', 'Backspace', 'Escape']) assert.equal(keyActionOf({ key }, ''), null, `${key} 는 고른 부품이 없으면 아무 일도 안 한다`)
23
+ })
24
+
25
+ test('칸에 치는 키는 칸의 것 — 이름 칸의 Backspace 는 글자를 지운다', () => {
26
+ for (const tagName of ['INPUT', 'select', 'TEXTAREA'])
27
+ assert.equal(keyActionOf({ key: 'Backspace', target: { tagName } }, 'arm'), null, tagName)
28
+ assert.equal(keyActionOf({ key: 'z', ctrlKey: true, target: { tagName: 'INPUT' } }, ''), null, '칸 안의 Ctrl+Z 는 칸의 되돌리기')
29
+ assert.equal(keyActionOf({ key: 'Delete', target: { tagName: 'DIV', isContentEditable: true } }, 'arm'), null)
30
+ assert.equal(keyActionOf({ key: 'Delete', target: { tagName: 'CANVAS' } }, 'arm'), 'remove')
31
+ })
32
+
33
+ test('편집기가 창의 키를 듣고, 떼어지면 놓는다', () => {
34
+ const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
35
+ assert.ok(editor.includes("window.addEventListener('keydown', this.onKey)") && editor.includes("window.removeEventListener('keydown', this.onKey)"))
36
+ assert.ok(editor.includes('target: e.composedPath()[0] as HTMLElement'), '그림자 DOM 안의 칸도 칸으로 안다')
37
+ })
@@ -0,0 +1,78 @@
1
+ /*
2
+ * **V3 모델러의 구성 — V2 의 세 칸과 오른쪽 탭, 그보다 낫게** (사용자 2026-09-24: 「v2 모델러의 구성과 레이아웃이
3
+ * 좋았다. 그 보다도 더 좋아야지.」).
4
+ *
5
+ * V2 는 부품 목록 · 캔버스 · 오른쪽 칸(아이콘 탭 여섯)이었다. V3 는 부품 속성이 캔버스 아래 좁은 칸에 끼어 있었다.
6
+ * 이 시험은 소스에서 그 구성을 확인한다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
+ const editor = read('../client/modeller/v3-editor.ts')
15
+ const renderBody = editor.slice(editor.indexOf(' render() {'), editor.indexOf(' private renderField('))
16
+
17
+ test('세 칸 — 부품 목록 · 캔버스 · 오른쪽 칸, 캔버스 아래에는 아무것도 없다', () => {
18
+ /* The three panes are ox-three-pane's (ADR-0060 decision 8): parts in start, the canvas in the middle, the side in end. */
19
+ const main = renderBody.slice(renderBody.indexOf('<ox-three-pane'), renderBody.indexOf('</ox-three-pane>'))
20
+ const order = ['<figure-v3-parts', '<figure-v3-canvas', '<aside slot="end">'].map(tag => main.indexOf(tag))
21
+ assert.match(main, /<figure-v3-parts\s+slot="start"/, '부품 목록은 왼쪽 칸')
22
+ assert.ok(order.every(i => i >= 0) && order[0]! < order[1]! && order[1]! < order[2]!, '부품 · 캔버스 · 오른쪽 칸 순서')
23
+ assert.equal((main.match(/<aside/g) ?? []).length, 1, '오른쪽 칸은 하나 — 페이지가 따로 칸을 두지 않는다')
24
+ assert.doesNotMatch(read('../client/pages/figure-v3-modeller-page.ts'), /<aside/)
25
+ })
26
+
27
+ test('오른쪽 칸의 탭 — V2 의 여섯: 속성 · 능력 · 움직임 · 판정 · 환경 · 이력, 판정의 개수는 탭에 남는다', () => {
28
+ const tabs = [...renderBody.matchAll(/this\.renderTab\('([a-z]+)'/g)].map(m => m[1])
29
+ assert.deepEqual(tabs, ['properties', 'capability', 'motion', 'verdict', 'view', 'history'])
30
+ for (const tab of tabs) assert.ok(renderBody.includes(`?off=\${tab !== '${tab}'}`), `${tab} 는 감추되 떼지 않는다`)
31
+ })
32
+
33
+ test('부품을 고르면 — 도형 전체의 탭(판정 · 이력)은 그 부품의 속성으로 넘어가고, 부품 탭은 새 부품을 보여 준다', () => {
34
+ const choose = editor.slice(editor.indexOf(' private choose('), editor.indexOf(' private asset()'))
35
+ assert.match(choose, /this\.selected = id/)
36
+ assert.match(choose, /if \(id && \(this\.tab === 'verdict' \|\| this\.tab === 'view' \|\| this\.tab === 'history'\)\) this\.tab = 'properties'/)
37
+ for (const from of ['@select-part=${(e: CustomEvent) => this.choose(e.detail.id)}', '@part-selected=${(e: CustomEvent<string>) => this.choose(e.detail)}'])
38
+ assert.ok(renderBody.includes(from), from)
39
+ })
40
+
41
+ test('부품을 보고 있으면 도형 전체로 돌아가는 칩이 탭마다 있다', () => {
42
+ assert.ok((renderBody.match(/\$\{this\.renderSubject\(\)\}/g) ?? []).length >= 3)
43
+ assert.match(editor.slice(editor.indexOf(' private renderSubject()')), /@click=\$\{\(\) => \(this\.selected = ''\)\}/)
44
+ })
45
+
46
+ test('탭마다 제자리 — 속성엔 부품의 모양 · 재질, 자리와 능력엔 사다리와 자리, 움직임엔 움직임 · 조작값', () => {
47
+ const panel = (tab: string) => {
48
+ const start = renderBody.indexOf(`?off=\${tab !== '${tab}'}`)
49
+ return renderBody.slice(start, renderBody.indexOf('<div panel', start + 1))
50
+ }
51
+ assert.ok(panel('properties').includes("this.renderLinks(unavailable, 'shape')") && panel('properties').includes('<figure-v3-material'))
52
+ assert.ok(panel('capability').includes("this.renderLinks(unavailable, 'places')") && panel('capability').includes('<figure-v3-capability'))
53
+ assert.ok(panel('motion').includes("this.renderLinks(unavailable, 'motion')") && panel('motion').includes('this.renderControls('))
54
+ })
55
+
56
+ test('띠 — 칸 접기 · 되돌리기 · 전체 보기 · 저장 상태 · 저장, 말은 번역에서', () => {
57
+ /* The bar is ox-canvas-bar: folds at its edges (it folds the frame it is given), undo · redo · fit in the suite's places. */
58
+ const bar = renderBody.slice(renderBody.indexOf('<ox-canvas-bar'), renderBody.indexOf('</ox-canvas-bar>'))
59
+ for (const piece of ['.pane=${this.panes}', 'history', 'fit', 'undo-shortcut="Ctrl+Z"', 'redo-shortcut="Ctrl+Y"', 'session!.undo()', 'session!.redo()', '.fit()', 'role="status"', '@click=${this.save}'])
60
+ assert.ok(bar.includes(piece), piece)
61
+ assert.doesNotMatch(renderBody, /[가-힣]/, '편집기의 화면 말에 한국어가 박혀 있지 않다')
62
+ const keys = [...editor.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
63
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
64
+ const table = JSON.parse(read(`../translations/${lang}.json`))
65
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
66
+ }
67
+ })
68
+
69
+ test('탭 아이콘은 오른쪽 칸 맨 위 — 타입 칸은 V2 처럼 속성 탭 안의 첫 칸', () => {
70
+ const aside = renderBody.slice(renderBody.indexOf('<aside slot="end">'), renderBody.indexOf('</aside>'))
71
+ assert.match(aside, /^<aside slot="end">\s*<div tabs>/, '탭이 첫째')
72
+ const properties = aside.slice(aside.indexOf("?off=${tab !== 'properties'}"), aside.indexOf("?off=${tab !== 'capability'}"))
73
+ assert.ok(properties.includes('<slot name="figure"></slot>'))
74
+ const figure = read('../client/modeller/figure-v3-properties.ts')
75
+ assert.match(figure, /<h3>\$\{i18next\.t\('figure\.label\.group-figure'\)\}<\/h3>\s*<slot name="type"><\/slot>/, '타입은 「도형」 절의 첫 칸')
76
+ const page = read('../client/pages/figure-v3-modeller-page.ts')
77
+ assert.ok(page.includes('<label slot="type" type-field>'))
78
+ })
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict'
10
10
  import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset } from '@hatiolab/figure-model'
11
11
  import type { V3Asset } from '@hatiolab/figure-model'
12
12
 
13
- import { canFasten, canFollow, fastenedTo, followOf, hasInstanceSize, motionOf, positionsWorkedOut } from '../client/modeller/v3-links.ts'
13
+ import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, rangeDiffers, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/v3-links.ts'
14
14
 
15
15
  const pair = (): V3Asset => {
16
16
  let asset = createV3Asset('Pair')
@@ -28,7 +28,7 @@ test('크기 — 제 치수 · 인스턴스 크기 · 다른 부품의 치수를
28
28
  const asset = pair()
29
29
  assert.deepEqual(followOf(asset, 'plate', 'x'), { of: 'own' })
30
30
  const byInstance = applyV3Authoring(asset, { kind: 'link-dimension', part: 'plate', axis: 'x', source: { of: 'instance', axis: 'x' } })
31
- assert.deepEqual(followOf(byInstance, 'plate', 'x'), { of: 'instance', axis: 'x' })
31
+ assert.deepEqual(followOf(byInstance, 'plate', 'x'), { of: 'instance', axis: 'x', times: 1 })
32
32
  const byPart = applyV3Authoring(byInstance, { kind: 'link-dimension', part: 'block', axis: 'x', source: { of: 'part', part: 'plate', axis: 'x' }, times: 0.5 })
33
33
  assert.deepEqual(followOf(byPart, 'block', 'x'), { of: 'part', part: 'plate', axis: 'x' }, '배율이 끼어도 원천을 찾는다')
34
34
  const back = applyV3Authoring(byPart, { kind: 'unlink-dimension', part: 'block', axis: 'x', mm: 300 })
@@ -46,7 +46,8 @@ test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', ()
46
46
  const withDrum = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 50, z: 0 } })
47
47
  assert.equal(canFasten(withDrum, 'block'), true)
48
48
  assert.equal(canFasten(withDrum, 'drum'), true, '원기둥도 면으로 붙인다(figure-model 0.1.47)')
49
- assert.equal(canFollow(withDrum, 'drum'), false, '원기둥의 치수 셋은 X·Y·Z 가 아니라 따르기는 상자만')
49
+ assert.equal(canFollow(withDrum, 'drum'), true, '원기둥은 반지름(X)과 길이(Y)를 따를 수 있다')
50
+ assert.deepEqual(followableAxes(withDrum, 'drum'), ['x', 'y'], '원기둥에는 제 Z 가 없다')
50
51
  })
51
52
 
52
53
  test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는다', () => {
@@ -81,7 +82,8 @@ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.m
81
82
 
82
83
  test('고르개의 값은 지금 따르는 것을 가리킨다', () => {
83
84
  assert.equal(choiceOf({ of: 'own' }), 'own')
84
- assert.equal(choiceOf({ of: 'instance', axis: 'y' }), 'instance:y')
85
+ assert.equal(choiceOf({ of: 'instance', axis: 'y', times: 1 }), 'instance:y')
86
+ assert.equal(choiceOf({ of: 'instance', axis: 'y', times: 0.25 }), 'share:y', '몫은 비례로')
85
87
  assert.equal(choiceOf({ of: 'part', part: 'plate', axis: 'x' }), 'part:plate:x')
86
88
  })
87
89
 
@@ -95,8 +97,8 @@ test('편집기는 세 칸의 명령을 세션의 이력 하나로 돌리고,
95
97
 
96
98
  test('떼면 지금 크기로 고정한다 — 따르던 값을 잃지 않는다', () => {
97
99
  const links = read('../client/modeller/figure-v3-links.ts')
98
- assert.match(links, /kind: 'unlink-dimension', part: this\.partId, axis, mm \}/)
99
- assert.match(links, /this\.dims\?\.\[AXES\.indexOf\(axis\)\]/)
100
+ assert.ok(links.includes("this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm: Math.max(1, Math.round(current)) })"))
101
+ assert.ok(links.includes('const current = dimensionNow(asset, this.partId, axis) ?? this.dims?.[AXES.indexOf(axis)] ?? 100'))
100
102
  })
101
103
 
102
104
  test('세 칸의 말은 다섯 말 모두에 있다', () => {
@@ -104,7 +106,7 @@ test('세 칸의 말은 다섯 말 모두에 있다', () => {
104
106
  const keys = [...links.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
105
107
  for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
106
108
  const table = JSON.parse(read(`../translations/${lang}.json`))
107
- for (const key of [...keys, 'figure.text.follow-would-loop', 'figure.text.fasten-would-loop', 'figure.label.motion-slide', 'figure.label.motion-turn'])
109
+ for (const key of [...keys, 'figure.text.follow-would-loop', 'figure.text.fasten-would-loop', 'figure.label.motion-slide', 'figure.label.motion-turn', 'figure.label.place-grip', 'figure.label.place-seat'])
108
110
  assert.ok(table[key], `${lang}: ${key}`)
109
111
  }
110
112
  })
@@ -121,3 +123,112 @@ test('도형 크기를 따라 움직이는 자리를 가려 읽고, 여기에
121
123
  assert.deepEqual(positionsWorkedOut(fixed, 'lamp'), [])
122
124
  assert.equal(fixed.designInputs['lamp.position.y'], 110, '지금 자리 그대로 — 100 + 10')
123
125
  })
126
+
127
+ test('자리 — 부품의 면에 둔 집는 자리·놓는 자리를 그 부품에서 읽는다', () => {
128
+ let asset = pair()
129
+ asset = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 300 }, position: { x: 0, y: 400, z: 0 } } as never)
130
+ assert.deepEqual(placeFacesOf(asset, 'block'), ['left', 'right', 'bottom', 'top', 'back', 'front'])
131
+ assert.deepEqual(placeFacesOf(asset, 'drum'), [], '둥근 몸에는 자리를 권하지 않는다 — 명령이 거절할 것이다')
132
+
133
+ assert.equal(placeIdFor(asset, 'block', 'grip'), 'block-grip')
134
+ asset = applyV3Authoring(asset, { kind: 'add-binding', id: placeIdFor(asset, 'block', 'grip'), label: '집는 자리', role: 'grip', part: 'block', face: 'top', accepts: acceptsOf(' BOX, TOTE,, BOX '), capacity: 2 })
135
+ assert.equal(placeIdFor(asset, 'block', 'grip'), 'block-grip-2', '같은 부품에 둘째 자리는 다른 id')
136
+ assert.deepEqual(
137
+ placesOn(asset, 'block').map(p => [p.id, p.role, p.face, p.accepts, p.capacity]),
138
+ [['block-grip', 'grip', 'top', ['BOX', 'TOTE'], 2]]
139
+ )
140
+ assert.deepEqual(placesOn(asset, 'plate'), [], '다른 부품의 자리는 안 섞인다')
141
+ })
142
+
143
+ test('담는 능력이 없는 도형은 자리가 있어도 보드가 아무것도 넘기지 않는다 — 화면이 그것을 말한다', () => {
144
+ const asset = pair()
145
+ assert.equal(holdsThings(asset), false)
146
+ asset.document.capabilities.names = ['Capacity', 'Holdable']
147
+ assert.equal(holdsThings(asset), true)
148
+ asset.document.capabilities.names = ['FlowNode']
149
+ assert.equal(holdsThings(asset), true, 'FlowNode 는 Capacity 를 품는다')
150
+
151
+ const links = read('../client/modeller/figure-v3-links.ts')
152
+ assert.match(links, /holdsThings\(asset\)/)
153
+ assert.match(links, /kind: 'set-capabilities', names/)
154
+ const editor = read('../client/modeller/v3-editor.ts')
155
+ /* A slice and `includes`, not a lazy regex over the whole editor: that hangs when it does not match. */
156
+ const start = editor.indexOf('<figure-v3-links')
157
+ const block = editor.slice(start, editor.indexOf('</figure-v3-links>', start))
158
+ assert.ok(start >= 0 && block.includes('@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}'), '자리 절의 능력 선언이 편집기에 닿는다')
159
+ })
160
+
161
+ test('받는 종류가 없으면 자리를 만들지 않는다 — 형식에 「아무거나」가 없다', () => {
162
+ const links = read('../client/modeller/figure-v3-links.ts')
163
+ assert.match(links, /\?disabled=\$\{this\.locked \|\| !accepts\.length \|\| !capacityOk\}/)
164
+ assert.deepEqual(acceptsOf(' , ,'), [])
165
+ })
166
+
167
+ /* ── V2's proportional rules (user 2026-09-24: ROBOT_ARM at twice the size looked broken) ───────────── */
168
+
169
+ const sizedPair = () => {
170
+ let asset = createV3Asset('Probe')
171
+ asset = addV3Box(asset, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
172
+ asset = addV3Primitive(asset, { id: 'flange', kind: 'cylinder', dimensions: { radius: 20, length: 10 }, position: { x: 0, y: 95, z: 0 } })
173
+ asset.document.model.inputs.push({ id: 'size.y', unit: 'mm', min: 1, max: 20000, role: 'design' } as never)
174
+ asset.designInputs['size.y'] = 100
175
+ return asset
176
+ }
177
+
178
+ test('비례 — 인스턴스 크기의 몫을 따르는 치수는 비례로 읽고, 「남은 만큼 채우기」는 비례가 아니다', () => {
179
+ const asset = sizedPair()
180
+ assert.equal(dimensionNow(asset, 'flange', 'y'), 10, '원기둥의 Y 는 길이')
181
+ assert.equal(dimensionNow(asset, 'flange', 'x'), 20, '원기둥의 X 는 반지름')
182
+ const share = applyV3Authoring(asset, { kind: 'link-dimension', part: 'flange', axis: 'y', source: { of: 'instance', axis: 'y' }, times: 0.1 })
183
+ assert.deepEqual(followOf(share, 'flange', 'y'), { of: 'instance', axis: 'y', times: 0.1 })
184
+
185
+ /* V2's span: the size less what the others take -- a sum, not a share (ROBOT_ARM's tool flange). */
186
+ const fill = structuredClone(asset)
187
+ const m = fill.document.model as unknown as { nodes: { id: string; op: string; args: string[]; outputs: Record<string, string> }[]; constants: { id: string; unit: string; value: number }[] }
188
+ m.constants.push({ id: 'k.gaps', unit: 'mm', value: -90 })
189
+ m.nodes.push({ id: 'flange.free', op: 'add@1', args: ['size.y', 'k.gaps'], outputs: { value: 'flange.free.value' } })
190
+ m.nodes.push({ id: 'flange.dimension.y', op: 'mul@1', args: ['flange.free.value', m.constants.find(c => c.value === 1)?.id ?? 'k.one'], outputs: { value: 'flange.dimension.y.value' } })
191
+ if (!m.constants.some(c => c.value === 1)) m.constants.push({ id: 'k.one', unit: 'ratio', value: 1 })
192
+ assert.deepEqual(followOf(fill as V3Asset, 'flange', 'y'), { of: 'expression' })
193
+
194
+ /* Before any link the shape reads the measure directly; a worked-out one there is not 「고정」 (ROBOT_ARM on :3300). */
195
+ const direct = structuredClone(asset)
196
+ const d = direct.document.model as unknown as typeof m
197
+ d.constants.push({ id: 'k.gaps', unit: 'mm', value: -90 })
198
+ d.nodes.push({ id: 'flange.free', op: 'add@1', args: ['size.y', 'k.gaps'], outputs: { value: 'flange.free.value' } })
199
+ d.nodes.find(n => n.id === 'flange.shape')!.args[1] = 'flange.free.value'
200
+ assert.deepEqual(followOf(direct as V3Asset, 'flange', 'y'), { of: 'expression' })
201
+ assert.deepEqual(followOf(asset, 'flange', 'y'), { of: 'own' }, '제 숫자는 고정')
202
+ })
203
+
204
+ test('자리를 크기에 비례로 두면 그렇게 읽고, 다시 권하지 않는다', () => {
205
+ let asset = sizedPair()
206
+ asset = applyV3Authoring(asset, { kind: 'scale-position', part: 'flange', axis: 'y' })
207
+ assert.equal(positionIsShare(asset, 'flange', 'y'), true)
208
+ assert.equal(positionIsShare(asset, 'base', 'y'), false)
209
+ const links = read('../client/modeller/figure-v3-links.ts')
210
+ assert.ok(links.includes("hasInstanceSize(asset, axis) && !positionIsShare(asset, this.partId, axis)"))
211
+ assert.ok(links.includes("this.command({ kind: 'scale-position', part: this.partId, axis })"))
212
+ assert.ok(links.includes("const times = Math.round((current / size) * 1e6) / 1e6"), '비례는 지금 치수의 몫')
213
+ })
214
+
215
+ test('놓일 수 있는 크기 — 남은 만큼 채우던 부품을 비례로 바꾸면 다시 재어 넓힌다', () => {
216
+ assert.equal(instanceRangeOf(createV3Asset('Probe')), undefined, '크기를 선언하지 않은 도형에는 범위가 없다')
217
+ let asset = sizedPair()
218
+ for (const axis of ['x', 'z'] as const) {
219
+ asset.document.model.inputs.push({ id: `size.${axis}`, unit: 'mm', min: 1, max: 20000, role: 'design' } as never)
220
+ asset.designInputs[`size.${axis}`] = 200
221
+ }
222
+ const y = asset.document.model.inputs.find(i => i.id === 'size.y')!
223
+ y.min = 90
224
+ y.max = 1000
225
+ const range = instanceRangeOf(asset)!
226
+ assert.deepEqual(range.declared.y, { min: 90, max: 1000 })
227
+ assert.deepEqual(range.measured.y, { min: 1, max: 1000 }, '기본 범위: 1 mm 에서 열 배')
228
+ assert.equal(rangeDiffers(range.declared, range.measured), true)
229
+ asset = applyV3Authoring(asset, { kind: 'declare-instance-size', range: range.measured, replace: true })
230
+ assert.equal(rangeDiffers(instanceRangeOf(asset)!.declared, instanceRangeOf(asset)!.measured), false, '다시 재면 같다')
231
+ const editor = read('../client/modeller/v3-editor.ts')
232
+ assert.ok(editor.includes("this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })"))
233
+ })
234
+