@things-factory/figure-ui 10.1.46 → 10.1.48

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 (148) 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 +61 -81
  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 +254 -262
  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 +566 -223
  18. package/client/modeller/v3-fields.ts +84 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-joint-fan.ts +292 -0
  21. package/client/modeller/v3-keys.ts +34 -0
  22. package/client/modeller/v3-links.ts +101 -9
  23. package/client/modeller/v3-live.ts +79 -0
  24. package/client/modeller/v3-maturity.ts +415 -0
  25. package/client/modeller/v3-palette.ts +45 -0
  26. package/client/modeller/v3-preview-model.ts +54 -0
  27. package/client/modeller/v3-stage-model.ts +159 -0
  28. package/client/modeller/v3-thumbnail.ts +8 -4
  29. package/client/pages/figure-list-page.ts +4 -4
  30. package/client/pages/figure-v3-modeller-page.ts +66 -134
  31. package/dist-client/modeller/angle-dial.d.ts +32 -0
  32. package/dist-client/modeller/angle-dial.js +41 -0
  33. package/dist-client/modeller/angle-dial.js.map +1 -0
  34. package/dist-client/modeller/figure-settings.d.ts +39 -0
  35. package/dist-client/modeller/figure-settings.js +317 -0
  36. package/dist-client/modeller/figure-settings.js.map +1 -0
  37. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  38. package/dist-client/modeller/figure-v3-capability.js +141 -0
  39. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  40. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  41. package/dist-client/modeller/figure-v3-clips.js +268 -0
  42. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  43. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  44. package/dist-client/modeller/figure-v3-controls.js +226 -0
  45. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  46. package/dist-client/modeller/figure-v3-links.d.ts +4 -1
  47. package/dist-client/modeller/figure-v3-links.js +64 -82
  48. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  49. package/dist-client/modeller/figure-v3-material.d.ts +4 -1
  50. package/dist-client/modeller/figure-v3-material.js +109 -35
  51. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  52. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  53. package/dist-client/modeller/figure-v3-parts.js +88 -3
  54. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  55. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  56. package/dist-client/modeller/figure-v3-preview.js +322 -0
  57. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  58. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  59. package/dist-client/modeller/figure-v3-properties.js +29 -57
  60. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  61. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  62. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  63. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  64. package/dist-client/modeller/figure-view.d.ts +118 -0
  65. package/dist-client/modeller/figure-view.js +78 -0
  66. package/dist-client/modeller/figure-view.js.map +1 -0
  67. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  68. package/dist-client/modeller/side-panel-styles.js +326 -0
  69. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  70. package/dist-client/modeller/v3-canvas.d.ts +44 -37
  71. package/dist-client/modeller/v3-canvas.js +230 -260
  72. package/dist-client/modeller/v3-canvas.js.map +1 -1
  73. package/dist-client/modeller/v3-clips.d.ts +39 -0
  74. package/dist-client/modeller/v3-clips.js +58 -0
  75. package/dist-client/modeller/v3-clips.js.map +1 -0
  76. package/dist-client/modeller/v3-controls.d.ts +28 -0
  77. package/dist-client/modeller/v3-controls.js +28 -0
  78. package/dist-client/modeller/v3-controls.js.map +1 -0
  79. package/dist-client/modeller/v3-editor.d.ts +73 -1
  80. package/dist-client/modeller/v3-editor.js +593 -218
  81. package/dist-client/modeller/v3-editor.js.map +1 -1
  82. package/dist-client/modeller/v3-fields.d.ts +39 -0
  83. package/dist-client/modeller/v3-fields.js +69 -0
  84. package/dist-client/modeller/v3-fields.js.map +1 -0
  85. package/dist-client/modeller/v3-help.d.ts +18 -0
  86. package/dist-client/modeller/v3-help.js +37 -0
  87. package/dist-client/modeller/v3-help.js.map +1 -0
  88. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  89. package/dist-client/modeller/v3-joint-fan.js +264 -0
  90. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  91. package/dist-client/modeller/v3-keys.d.ts +14 -0
  92. package/dist-client/modeller/v3-keys.js +23 -0
  93. package/dist-client/modeller/v3-keys.js.map +1 -0
  94. package/dist-client/modeller/v3-links.d.ts +35 -1
  95. package/dist-client/modeller/v3-links.js +101 -8
  96. package/dist-client/modeller/v3-links.js.map +1 -1
  97. package/dist-client/modeller/v3-live.d.ts +31 -0
  98. package/dist-client/modeller/v3-live.js +58 -0
  99. package/dist-client/modeller/v3-live.js.map +1 -0
  100. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  101. package/dist-client/modeller/v3-maturity.js +317 -0
  102. package/dist-client/modeller/v3-maturity.js.map +1 -0
  103. package/dist-client/modeller/v3-palette.d.ts +7 -0
  104. package/dist-client/modeller/v3-palette.js +42 -0
  105. package/dist-client/modeller/v3-palette.js.map +1 -0
  106. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  107. package/dist-client/modeller/v3-preview-model.js +48 -0
  108. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  109. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  110. package/dist-client/modeller/v3-stage-model.js +121 -0
  111. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  112. package/dist-client/modeller/v3-thumbnail.js +6 -3
  113. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  114. package/dist-client/pages/figure-list-page.js +4 -4
  115. package/dist-client/pages/figure-list-page.js.map +1 -1
  116. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  117. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  118. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  119. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  120. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  121. package/dist-client/tsconfig.tsbuildinfo +1 -1
  122. package/package.json +6 -6
  123. package/test/v3-clips.test.ts +70 -0
  124. package/test/v3-controls.test.ts +68 -0
  125. package/test/v3-fields.test.ts +71 -0
  126. package/test/v3-gizmo.test.ts +3 -5
  127. package/test/v3-joint-fan.test.ts +210 -0
  128. package/test/v3-keys.test.ts +37 -0
  129. package/test/v3-layout.test.ts +78 -0
  130. package/test/v3-links.test.ts +89 -6
  131. package/test/v3-live.test.ts +99 -0
  132. package/test/v3-material.test.ts +6 -0
  133. package/test/v3-maturity.test.ts +268 -0
  134. package/test/v3-palette-icons.test.ts +22 -0
  135. package/test/v3-parts-wiring.test.ts +3 -1
  136. package/test/v3-preview.test.ts +42 -0
  137. package/test/v3-properties.test.ts +4 -2
  138. package/test/v3-scene-canvas.test.ts +85 -0
  139. package/test/v3-stage-model.test.ts +25 -0
  140. package/test/v3-theme.test.ts +16 -25
  141. package/test/v3-thumbnail.test.ts +29 -0
  142. package/test/v3-verdict.test.ts +12 -6
  143. package/test/v3-view.test.ts +20 -0
  144. package/translations/en.json +90 -6
  145. package/translations/ja.json +92 -8
  146. package/translations/ko.json +90 -6
  147. package/translations/ms.json +92 -8
  148. package/translations/zh.json +92 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.46",
3
+ "version": "10.1.48",
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.48",
30
- "@hatiolab/things-scene": "^10.1.26",
29
+ "@hatiolab/figure-model": "^0.1.54",
30
+ "@hatiolab/things-scene": "^10.1.32",
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.46",
40
+ "@things-factory/figure-service": "^10.1.48",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "d3467cde550453f3348fed0ed6c79a12a6ba0db4"
43
+ "gitHead": "448b0d568a1816d25aa49f3b6233d8086d6011f9"
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,71 @@
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, repeatOf } 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
+
63
+ test('되풀이한 부품의 속성 이름 칸도 사람의 이름이고, 몇 개 중 몇 번째인지 말한다 — 날 id 를 보이지 않는다', () => {
64
+ const geometry = Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'roller.value']) })).concat([{ id: 'deck' }])
65
+ assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', geometry), { name: 'roller', count: 16, index: 15 })
66
+ assert.equal(repeatOf('deck', geometry), null)
67
+ const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
68
+ assert.ok(editor.includes('.value=${partLabelOf(this.selected)}'), '사용자 2026-09-24: 「부품 이름 ???」')
69
+ assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}'), '복사본에는 바꿀 제 이름이 없다')
70
+ })
71
+
@@ -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,210 @@
1
+ /*
2
+ * **고른 부품이 어디서 얼마나 도는지** (사용자 2026-09-24: 「로봇암은 편집기에서 원래는 부품을 선택했을 때, 가변 각도가
3
+ * 표현이 되었었는데」). V2 는 관절의 가동 범위를 부채꼴로 저작면에 그렸다. 부채꼴의 두 끝이 제어값의 최솟값과 최댓값에서
4
+ * 부품이 실제로 가는 쪽을 가리키는지를 — 평가해서 — 본다.
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, compileV3Asset, convertV2ToV3, createV3Asset } from '@hatiolab/figure-model'
12
+ import type { V3Asset } from '@hatiolab/figure-model'
13
+
14
+ import { JOINT_FAN, jointFanOf } from '../client/modeller/v3-joint-fan.ts'
15
+
16
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
+
18
+ function arm(): V3Asset {
19
+ let asset = createV3Asset('Probe')
20
+ asset = addV3Box(asset, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
21
+ asset = addV3Box(asset, { id: 'arm', size: { x: 400, y: 50, z: 50 }, position: { x: 200, y: 125, z: 0 } })
22
+ return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'arm.turn', unit: 'deg', min: -45, max: 90 } })
23
+ }
24
+
25
+ /** The fan's profile point in the figure's frame. The polygon lies in its local X–Z: profile (x, y) is local (x, 0, y). */
26
+ const world = (fan: { pose: { t: number[]; r: number[][] } }, p: { x: number; y: number }) =>
27
+ [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
28
+
29
+ const direction = (v: number[]) => {
30
+ const l = Math.hypot(v[0]!, v[2]!)
31
+ return [v[0]! / l, v[2]! / l]
32
+ }
33
+
34
+ test('a part that turns gets a fan from the lowest value to the highest, where the part really goes', () => {
35
+ const asset = arm()
36
+ const fan = jointFanOf(asset, 'arm') as unknown as { id: string; shape: { provider: string; profile: { x: number; y: number }[] }; pose: { t: number[]; r: number[][] } }
37
+ assert.ok(fan)
38
+ assert.equal(fan.id, JOINT_FAN)
39
+ assert.equal(fan.shape.provider, 'polygon')
40
+ const profile = fan.shape.profile
41
+ assert.deepEqual(profile[0], { x: 0, y: 0 }, 'not a full turn: the fan starts at the pivot')
42
+
43
+ const armAt = (deg: number) => {
44
+ const body = compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, 'arm.turn': deg } }).evaluate().geometry.find(g => g.id === 'arm')!
45
+ return direction((body as unknown as { pose: { t: number[] } }).pose.t)
46
+ }
47
+ const first = direction(world(fan, profile[1]!))
48
+ const last = direction(world(fan, profile[profile.length - 1]!))
49
+ for (const [got, want] of [
50
+ [first, armAt(-45)],
51
+ [last, armAt(90)]
52
+ ] as const) {
53
+ assert.ok(Math.abs(got[0]! - want[0]!) < 1e-6 && Math.abs(got[1]! - want[1]!) < 1e-6, `${got} ≈ ${want}`)
54
+ }
55
+ })
56
+
57
+ test('a fan is as large as what turns, and never smaller than 60 mm', () => {
58
+ const big = jointFanOf(arm(), 'arm') as unknown as { shape: { profile: { x: number; y: number }[] } }
59
+ const radius = Math.max(...big.shape.profile.map(p => Math.hypot(p.x, p.y)))
60
+ assert.ok(Math.abs(radius - 240) < 1e-3, `0.6 × 400 — ${radius}`)
61
+ })
62
+
63
+ test('no fan for a part that does not turn, nor for a slide -- a fan in a guessed plane would point the wrong way', () => {
64
+ assert.equal(jointFanOf(arm(), 'base'), null)
65
+ assert.equal(jointFanOf(arm(), ''), null)
66
+ let slider = createV3Asset('Probe')
67
+ slider = addV3Box(slider, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
68
+ slider = applyV3Authoring(slider, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60 } })
69
+ assert.equal(jointFanOf(slider, 'block'), null)
70
+ })
71
+
72
+ test('the editor draws it for the selected part, with the pose in hand; the canvas never lets it be picked', () => {
73
+ const editor = read('../client/modeller/v3-editor.ts')
74
+ assert.ok(editor.includes('.fan=${this.fanOf(asset as V3Asset | undefined)}'))
75
+ assert.ok(editor.includes('return jointFanOf(asset, this.selected, this.live.values)'))
76
+ const canvas = read('../client/modeller/v3-canvas.ts')
77
+ assert.ok(canvas.includes('{ ...fan, capturable: false, locked: true }'))
78
+ })
79
+
80
+ /* ── Joints the converter wrote (user 2026-09-24: 「로봇암 axis와 관절의 각도 표현을 다시 검증해보라」) ───────── */
81
+
82
+ /** A V2 column: a link on a hinge at the root, a tip on a wrist inside it -- a first joint and a nested one. */
83
+ function column(): V3Asset {
84
+ const source = {
85
+ version: 2,
86
+ type: 'COLUMN',
87
+ base: { x: 200, y: 700, z: 200 },
88
+ parts: [
89
+ { name: 'base', primitive: 'cube', transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 200, y: 100, z: 200 } }, material: { token: 'palette.dark' } },
90
+ { name: 'link', primitive: 'cube', transform: { position: { x: 0, y: 300, z: 0 }, size: { x: 80, y: 400, z: 80 } }, material: { token: 'palette.primary' }, parent: 'base' },
91
+ { name: 'tip', primitive: 'cube', transform: { position: { x: 0, y: 600, z: 0 }, size: { x: 60, y: 200, z: 60 } }, material: { token: 'palette.primary' }, parent: 'link' },
92
+ /* Rides the hinge, centred on the wrist's axis -- the wrist's housing, as ROBOT_ARM's elbow-housing is the elbow's. */
93
+ { name: 'housing', primitive: 'cube', transform: { position: { x: 0, y: 500, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.dark' }, parent: 'link' },
94
+ /* Rides the hinge and holds no pivot. */
95
+ { name: 'badge', primitive: 'cube', transform: { position: { x: 60, y: 300, z: 0 }, size: { x: 10, y: 40, z: 40 } }, material: { token: 'palette.dark' }, parent: 'link' }
96
+ ],
97
+ joints: [
98
+ { name: 'hinge', child: 'link', type: 'revolute', origin: { x: 0, y: 100, z: 0 }, axis: { x: 1, y: 0, z: 0 }, limits: { min: -90, max: 60 } },
99
+ { name: 'wrist', child: 'tip', type: 'revolute', origin: { x: 0, y: 500, z: 0 }, axis: { x: 0, y: 0, z: 1 }, limits: { min: -120, max: 120 } }
100
+ ]
101
+ }
102
+ const result = convertV2ToV3(source, { palette: { 'palette.dark': '#333333', 'palette.primary': '#3366cc' } } as never)
103
+ assert.equal(result.status, 'converted', JSON.stringify((result as { refusals?: unknown }).refusals))
104
+ return (result as unknown as { asset: V3Asset }).asset
105
+ }
106
+
107
+ const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]!)
108
+ const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i]!, 0)
109
+ const angle = (a: number[], b: number[]) => (Math.acos(Math.max(-1, Math.min(1, dot(a, b) / (Math.hypot(...a) * Math.hypot(...b))))) * 180) / Math.PI
110
+ const turnOf = (r1: number[][], r2: number[][]) => {
111
+ let trace = 0
112
+ for (let i = 0; i < 3; i++) for (let k = 0; k < 3; k++) trace += r1[k]![i]! * r2[k]![i]!
113
+ return (Math.acos(Math.max(-1, Math.min(1, (trace - 1) / 2))) * 180) / Math.PI
114
+ }
115
+
116
+ /*
117
+ Checked against the motion, not against the fan's own sampling: where the part's centre really is at the control's
118
+ ends, and how far its orientation really turns, summed in small steps.
119
+ */
120
+ for (const [part, joint] of [
121
+ ['link', 'hinge'],
122
+ ['tip', 'wrist']
123
+ ] as const) {
124
+ test(`${part} on ${joint}: the axis holds the part's plane, the fan's ends are where it goes, the fan opens as far as it turns`, () => {
125
+ const asset = column()
126
+ const fan = jointFanOf(asset, part) as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
127
+ assert.ok(fan, 'a fan -- the first joint of a chain included')
128
+ const input = asset.document.model.inputs.find(i => i.id === joint)!
129
+ const pivot = fan.pose.t
130
+ const axis = [0, 1, 2].map(i => fan.pose.r[i]![1]!)
131
+ const at = (s: number) =>
132
+ compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, [joint]: s } }).evaluate().geometry.find(g => g.id === part) as unknown as { pose: { t: number[]; r: number[][] } }
133
+ const onFan = (p: { x: number; y: number }) => [0, 1, 2].map(i => pivot[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
134
+ const flat = (v: number[]) => sub(v, axis.map(a => a * dot(v, axis)))
135
+
136
+ const axial = [input.min!, 0, input.max!].map(s => dot(sub(at(s).pose.t, pivot), axis))
137
+ assert.ok(Math.max(...axial) - Math.min(...axial) < 1e-6, `the part stays in the fan's plane: ${axial}`)
138
+
139
+ const edges = fan.shape.profile.filter(p => p.x !== 0 || p.y !== 0)
140
+ assert.ok(angle(flat(sub(onFan(edges[0]!), pivot)), flat(sub(at(input.min!).pose.t, pivot))) < 0.01, 'the first edge is where the part is at the lowest value (the profile is kept to a micrometre)')
141
+ assert.ok(angle(flat(sub(onFan(edges[edges.length - 1]!), pivot)), flat(sub(at(input.max!).pose.t, pivot))) < 0.01, 'the last edge, at the highest')
142
+
143
+ let opens = 0,
144
+ turns = 0
145
+ for (let i = 1; i < edges.length; i++) opens += angle(sub(onFan(edges[i - 1]!), pivot), sub(onFan(edges[i]!), pivot))
146
+ for (let i = 1; i <= 60; i++) {
147
+ const s = (k: number) => input.min! + ((input.max! - input.min!) * k) / 60
148
+ turns += turnOf(at(s(i - 1)).pose.r, at(s(i)).pose.r)
149
+ }
150
+ assert.ok(Math.abs(opens - turns) < 0.01 && Math.abs(turns - (input.max! - input.min!)) < 0.01, `opens ${opens}, turns ${turns}`)
151
+ })
152
+ }
153
+
154
+ test('a housing shows the joint it houses, not the one that carries it; a part that holds no pivot shows none', () => {
155
+ const asset = column()
156
+ const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
157
+ assert.equal(turns('housing'), 'wrist', 'user 2026-09-24: 「elbow-housing itself is a axis」 -- it rides the hinge, it houses the wrist')
158
+ assert.equal(turns('link'), 'hinge', 'a link with pivots only at its ends shows the joint that turns it')
159
+ assert.equal(turns('tip'), 'wrist')
160
+ assert.equal(turns('badge'), null)
161
+ assert.equal(turns('base'), null)
162
+
163
+ /* The housing's fan is the wrist's: its ends are where the tip goes. */
164
+ const fan = jointFanOf(asset, 'housing') as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
165
+ const axis = [0, 1, 2].map(i => fan.pose.r[i]![1]!)
166
+ const flat = (v: number[]) => sub(v, axis.map(a => a * dot(v, axis)))
167
+ const onFan = (p: { x: number; y: number }) => [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
168
+ const tipAt = (s: number) => (compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, wrist: s } }).evaluate().geometry.find(g => g.id === 'tip') as unknown as { pose: { t: number[] } }).pose.t
169
+ const edges = fan.shape.profile.filter(p => p.x !== 0 || p.y !== 0)
170
+ assert.ok(angle(flat(sub(onFan(edges[0]!), fan.pose.t)), flat(sub(tipAt(-120), fan.pose.t))) < 0.01)
171
+ assert.ok(angle(flat(sub(onFan(edges[edges.length - 1]!), fan.pose.t)), flat(sub(tipAt(120), fan.pose.t))) < 0.01)
172
+ })
173
+
174
+ test('one joint, one fan: the housing and the part it turns show the same fan, sized from what turns', () => {
175
+ const asset = column()
176
+ const size = (part: string) => {
177
+ const fan = jointFanOf(asset, part) as unknown as { shape: { profile: { x: number; y: number }[] }; pose: { t: number[] } }
178
+ return { radius: Math.max(...fan.shape.profile.map(p => Math.hypot(p.x, p.y))), pivot: fan.pose.t }
179
+ }
180
+ const housing = size('housing'),
181
+ tip = size('tip')
182
+ assert.ok(Math.abs(housing.radius - tip.radius) < 1e-6, `user 2026-09-24: the elbow housing's fan was smaller than the forearm's -- ${housing.radius} vs ${tip.radius}`)
183
+ assert.deepEqual(housing.pivot, tip.pivot)
184
+ })
185
+
186
+ test('an axis running through a part does not make it that joint’s: a roll along a link shows on the roller, not the link', () => {
187
+ /* ROBOT_ARM's forearm showed the wrist roll, whose axis runs its length (2026-09-24 audit). */
188
+ const source = {
189
+ version: 2,
190
+ type: 'ROLLER',
191
+ base: { x: 200, y: 800, z: 200 },
192
+ parts: [
193
+ { name: 'base', primitive: 'cube', transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 200, y: 100, z: 200 } }, material: { token: 'palette.dark' } },
194
+ { name: 'link', primitive: 'cube', transform: { position: { x: 0, y: 300, z: 0 }, size: { x: 80, y: 400, z: 80 } }, material: { token: 'palette.primary' }, parent: 'base' },
195
+ { name: 'roller', primitive: 'cube', transform: { position: { x: 30, y: 600, z: 0 }, size: { x: 120, y: 200, z: 60 } }, material: { token: 'palette.primary' }, parent: 'link' }
196
+ ],
197
+ joints: [
198
+ { name: 'hinge', child: 'link', type: 'revolute', origin: { x: 0, y: 100, z: 0 }, axis: { x: 1, y: 0, z: 0 }, limits: { min: -90, max: 60 } },
199
+ { name: 'twist', child: 'roller', type: 'revolute', origin: { x: 0, y: 500, z: 0 }, axis: { x: 0, y: 1, z: 0 }, limits: { min: -170, max: 170 } }
200
+ ]
201
+ }
202
+ const result = convertV2ToV3(source, { palette: { 'palette.dark': '#333333', 'palette.primary': '#3366cc' } } as never)
203
+ assert.equal(result.status, 'converted')
204
+ const asset = (result as unknown as { asset: V3Asset }).asset
205
+ const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
206
+ assert.equal(turns('link'), 'hinge', 'the twist axis runs through the link, and the link does not twist')
207
+ assert.equal(turns('roller'), 'twist')
208
+ assert.equal(turns('base'), null)
209
+ })
210
+
@@ -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
+ })