@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
@@ -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 { acceptsOf, canFasten, canFollow, fastenedTo, followOf, hasInstanceSize, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/v3-links.ts'
13
+ import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, rangeDiffers, turnsAboutAJoint, 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('세 칸의 말은 다섯 말 모두에 있다', () => {
@@ -161,3 +163,84 @@ test('받는 종류가 없으면 자리를 만들지 않는다 — 형식에 「
161
163
  assert.match(links, /\?disabled=\$\{this\.locked \|\| !accepts\.length \|\| !capacityOk\}/)
162
164
  assert.deepEqual(acceptsOf(' , ,'), [])
163
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
+
235
+ test('관절이 있는 도형에는 한 비율로 키우기를 권하고, 선언하면 규칙으로 보인다(수석 아키텍트 판정 09-24)', () => {
236
+ let asset = sizedPair()
237
+ assert.equal(turnsAboutAJoint(asset), false)
238
+ asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'flange', motion: { kind: 'turn', axis: 'y' }, state: { id: 'flange.turn', unit: 'deg', min: 0, max: 90 } })
239
+ assert.equal(turnsAboutAJoint(asset), true)
240
+ const editor = read('../client/modeller/v3-editor.ts')
241
+ const rule = editor.slice(editor.indexOf(' private renderSizingRule('), editor.indexOf(' /** Choose a part,'))
242
+ assert.ok(rule.includes("asset.sizing === 'uniform'") && rule.includes("'figure.label.sizing-uniform'"), '선언하면 규칙을 말한다')
243
+ assert.ok(rule.includes("turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}"), '관절이 있으면 권한다')
244
+ assert.ok(rule.includes("this.linkCommand({ kind: 'scale-uniformly' })"), '누르면 선언한다 — 사람이 정한다')
245
+ })
246
+
@@ -0,0 +1,99 @@
1
+ /*
2
+ * **끄는 동안 자연스럽게** (사용자 2026-09-24: 「조작값도 인풋값이 변화하는 과정도 모두 이벤트처리해서 편집기에서도
3
+ * 자연스럽게 움직이면 좋겠다」). 끄는 중간값은 그 자세로 바로 그리고 아무것도 기록하지 않는다; 놓을 때 한 번 기록한다.
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 { addV3Box, applyV3Authoring, createV3Asset } from '@hatiolab/figure-model'
11
+
12
+ import { liveOverridesOf, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from '../client/modeller/v3-live.ts'
13
+
14
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
+
16
+ const slider = () => {
17
+ let asset = createV3Asset('Slider')
18
+ asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
19
+ return applyV3Authoring(asset, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60, start: 0 } })
20
+ }
21
+
22
+ test('끄는 값으로 그 자세를 바로 그린다 — 도형은 그대로', () => {
23
+ const asset = slider()
24
+ const before = JSON.stringify(asset)
25
+ const parts = livePartsOf(asset, { values: { shift: 45 } })!
26
+ assert.equal(Math.round(parts.find(p => p.id === 'block')!.pose.t[0]!), 45)
27
+ assert.equal(JSON.stringify(asset), before, '아무것도 기록하지 않는다')
28
+ assert.equal(livePartsOf(asset, NOTHING_LIVE), null, '끄는 것이 없으면 평소대로')
29
+ })
30
+
31
+ test('속성창의 설계 값도 치는 동안 그린다 — 크기가 따라 변하고 도형은 그대로', () => {
32
+ const asset = slider()
33
+ const before = JSON.stringify(asset)
34
+ const size = (parts: { id: string; shape?: unknown }[]) => JSON.stringify((parts.find(p => p.id === 'block') as { shape: unknown }).shape)
35
+ const now = livePartsOf(asset, { values: { 'block.size.x': 100 } })!
36
+ const wider = livePartsOf(asset, { values: { 'block.size.x': 300 } })!
37
+ assert.notEqual(size(wider), size(now), '크기 300 이 바로 그려진다')
38
+ assert.equal(JSON.stringify(asset), before, '기록은 놓을 때')
39
+ })
40
+
41
+ test('설계 값과 자세를 가른다 — 모르는 이름은 버린다', () => {
42
+ assert.deepEqual(liveOverridesOf(slider(), { shift: 10, 'block.size.x': 5, nothing: 1 }), { design: { 'block.size.x': 5 }, state: { shift: 10 } })
43
+ })
44
+
45
+ test('치는 도중 도형이 못 받는 값이면 지금 모습을 그린다 — 지어낸 도형을 보이지 않는다', () => {
46
+ assert.equal(livePartsOf(slider(), { values: { 'block.size.x': -5 } }), null)
47
+ })
48
+
49
+ test('재질의 끄는 값(발광)도 그린다 — 편집 하나를 기록 없이 적용해서', () => {
50
+ const asset = slider()
51
+ const glowing = livePartsOf(asset, {
52
+ values: {},
53
+ edit: { kind: 'set-material', target: 'block', material: { token: 'palette.primary', emissive: { token: 'palette.primary', intensity: 0.5, on: true } } }
54
+ })!
55
+ assert.equal(glowing.find(p => p.id === 'block')!.material.emissive?.intensity, 0.5)
56
+ assert.equal(asset.appearance[0]!.material.emissive, undefined, '도형은 그대로')
57
+ })
58
+
59
+ test('이름표를 끌면 값이 크기에 맞는 걸음으로 움직인다 — 각도 1°, 길이는 자릿수에 따라, Shift 는 열 배 곱게', () => {
60
+ assert.equal(scrubStep('deg', 30), 1)
61
+ assert.equal(scrubStep('mm', 50), 1)
62
+ assert.equal(scrubStep('mm', 12000), 100)
63
+ assert.equal(scrubStep('mm', 1200), 10)
64
+ assert.equal(scrubStep('mm', 1200, true), 1)
65
+ assert.equal(scrubStep('ratio', 0.4), 0.01)
66
+ assert.equal(scrubbed(1200, 7, 10), 1270)
67
+ assert.equal(scrubbed(0.4, 3, 0.01), 0.43, '0.43000000000000005 가 아니다')
68
+ })
69
+
70
+ test('속성창 숫자 칸은 치는 동안 알리고, 이름표를 끌 수 있다 — 기록은 change 에서', () => {
71
+ const editor = read('../client/modeller/v3-editor.ts')
72
+ assert.ok(editor.includes('@input=${(e: Event) => this.typing(group.cells[axis] as never, e)}'))
73
+ assert.ok(editor.includes('@input=${(e: Event) => this.typing(field as never, e)}'))
74
+ assert.ok(editor.includes('@pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}'))
75
+ for (const event of ['@v3-state-live=', '@v3-states-live=', '@v3-part-live=']) assert.ok(editor.includes(event), event)
76
+ assert.ok(editor.includes('livePartsOf(asset as V3Asset, this.live)') && editor.includes('.parts=${this.drawnParts(view.geometry, asset)}'), '캔버스가 끄는 값을 그린다')
77
+ const run = editor.slice(editor.indexOf(' private run(action'), editor.indexOf(' private hold('))
78
+ assert.ok(run.includes('this.live = NOTHING_LIVE'), '기록하거나 거절되면 끄는 값을 버린다')
79
+ const material = read('../client/modeller/figure-v3-material.ts')
80
+ assert.ok(material.includes('@input=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value), true)}'))
81
+ })
82
+
83
+ test('슬라이더와 클립 막대는 끄는 동안 알리고, 놓을 때 한 번 기록한다', () => {
84
+ const controls = read('../client/modeller/figure-v3-controls.ts')
85
+ assert.ok(controls.includes('@input=${(e: Event) => this.drag(c, number(e))}') && controls.includes('@change=${(e: Event) => this.try(c, number(e))}'))
86
+ assert.ok(controls.includes("new CustomEvent('v3-state-live'"))
87
+ const clips = read('../client/modeller/figure-v3-clips.ts')
88
+ assert.ok(clips.includes('@input=${(e: Event) => this.scrub(clip, number(e), true)}'))
89
+ assert.ok(clips.includes("new CustomEvent('v3-states-live'"))
90
+ })
91
+
92
+ test('클립 트랙의 머리는 이름 한 줄, 그 아래 반복 방식과 지우기 — 이름이 한 글자씩 쌓이지 않는다', () => {
93
+ const clips = read('../client/modeller/figure-v3-clips.ts')
94
+ const head = clips.slice(clips.indexOf(' div[track-head] {'), clips.indexOf(' div[keys] {'))
95
+ assert.ok(head.includes('display: grid;') && !head.includes('display: flex;'), '공통 스타일의 폭 100% 가 이름을 누르지 않는다')
96
+ assert.ok(head.includes('grid-column: 1 / -1;'), '이름은 제 줄')
97
+ assert.ok(head.includes('width: 30px;'), '지우기는 작은 버튼')
98
+ })
99
+
@@ -66,3 +66,9 @@ test('자산에 없는 색 역할은 그 색을 함께 보낸다 — 새 도형
66
66
  assert.match(material, /this\.palette\[token\] === undefined && offered\[token\] !== undefined/)
67
67
  assert.match(material, /\.\.\.\(Object\.keys\(palette\)\.length \? \{ palette \} : \{\}\)/)
68
68
  })
69
+
70
+ test('분할 수는 V2 처럼 — 흔한 넷은 다각형 윤곽과 삼각형 수로, 나머지는 접어 둔다', () => {
71
+ const source = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-material.ts', import.meta.url)), 'utf8')
72
+ assert.ok(source.includes('SEGMENT_PRESETS.slice(0, 4).map(') && source.includes('${silhouette(preset)}'))
73
+ assert.ok(source.includes('<details free') && source.includes('SEGMENT_PRESETS.slice(4)'))
74
+ })
@@ -0,0 +1,268 @@
1
+ /*
2
+ * **V3 성숙도 탭은 V2 리포트를 V3 수로 다시 보인다** (사용자 2026-09-24: 「분석부분도 많이 부족하고, 왜 이렇게 너프되었는지」).
3
+ *
4
+ * V2 리포트(figure-report.ts)는 개수 · 등급과 눈금 · 등급을 정한 한도 · 여섯 축 radar · 한도 막대 · 그리는 비용 · 재사용
5
+ * 추이를 보였다. 여기서는 그 수들이 V3 자산에서 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
+ import { addV3Box, addV3Primitive, applyV3Authoring, compileV3Asset, createV3Asset, LIMITS, v3ScoreOf } from '@hatiolab/figure-model'
14
+ import type { V3Asset } from '@hatiolab/figure-model'
15
+ import { acquireV3Geometry, releaseV3Geometry } from '@hatiolab/things-scene'
16
+
17
+ import {
18
+ BOARD_ASSETS,
19
+ budgetOf,
20
+ curveLayout,
21
+ decadeSteps,
22
+ GRADE_SCALE,
23
+ gradeOf,
24
+ V3_BUDGET_KEYS,
25
+ v3ReportOf
26
+ } from '../client/modeller/v3-maturity.ts'
27
+ import { capacityFrom } from '../client/modeller/scene-budget.ts'
28
+
29
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
30
+ const verdict = read('../client/modeller/figure-v3-verdict.ts')
31
+ const maturity = read('../client/modeller/v3-maturity.ts')
32
+
33
+ /** An asset with the given tokens in its palette, so boxes can differ in colour. */
34
+ function withPalette(...tokens: string[]): V3Asset {
35
+ const asset = createV3Asset('Probe')
36
+ for (const token of tokens) asset.palette[token] = '#336699'
37
+ return asset
38
+ }
39
+
40
+ const cube = (id: string, x: number, token = 'palette.primary', extra: Record<string, unknown> = {}) => ({
41
+ id,
42
+ size: { x: 100, y: 100, z: 100 },
43
+ position: { x, y: 50, z: 0 },
44
+ material: { token, ...extra }
45
+ })
46
+
47
+ test('부품이 없으면 리포트가 없다 — 0 으로 그리지 않는다', () => {
48
+ assert.equal(v3ReportOf(createV3Asset('Empty')), undefined)
49
+ })
50
+
51
+ test('등급은 V2 의 눈금이다 — 한도의 절반 A · 4분의 3 B · 한도 C · 넘치면 D', () => {
52
+ assert.deepEqual(GRADE_SCALE, [
53
+ ['A', 0.5],
54
+ ['B', 0.75],
55
+ ['C', 1]
56
+ ])
57
+ assert.equal(gradeOf(0.5), 'A')
58
+ assert.equal(gradeOf(0.51), 'B')
59
+ assert.equal(gradeOf(0.75), 'B')
60
+ assert.equal(gradeOf(1), 'C')
61
+ assert.equal(gradeOf(1.01), 'D')
62
+ })
63
+
64
+ test('등급은 figure-model 의 v3ScoreOf 그대로다 — 목록과 이 탭이 한 셈으로 매긴다', () => {
65
+ let asset = createV3Asset('Probe')
66
+ asset = addV3Box(asset, cube('a', 0))
67
+ const one = v3ReportOf(asset)!
68
+ assert.deepEqual(one.score, v3ScoreOf(asset))
69
+ assert.deepEqual(V3_BUDGET_KEYS, one.score.budgets.map(b => b.key), '화면이 말을 찾는 키와 셈의 키가 같다')
70
+ })
71
+
72
+ test('draw call 한도 3 은 없다 — 한 화면 166 대 목표에 견준다(수석 아키텍트 판정 09-24)', () => {
73
+ let asset = createV3Asset('Probe')
74
+ for (let i = 0; i < 4; i++) asset = addV3Box(asset, cube(`b${i}`, i * 200))
75
+ const four = v3ReportOf(asset)!
76
+ assert.equal(four.score.grade, 'A', '예전 한도 3 으로는 D 였다')
77
+ const groups = four.score.budgets.find(b => b.key === 'groups')!
78
+ assert.equal(groups.limit, LIMITS.groupsOnScreen / LIMITS.screenInstances)
79
+ assert.ok(four.capacity.instances >= 2 * LIMITS.screenInstances, 'A 는 목표의 두 배가 들어간다는 뜻')
80
+ })
81
+
82
+ test('감춘 부품은 그리지 않으므로 한도에 들지 않는다 — v3CostOf 와 같은 셈', () => {
83
+ let asset = createV3Asset('Probe')
84
+ asset = addV3Box(asset, cube('a', 0))
85
+ asset = addV3Box(asset, cube('b', 200))
86
+ const report = v3ReportOf(asset)!
87
+ assert.equal(report.score.budgets.find(b => b.key === 'groups')!.used, report.cost.groups)
88
+ assert.equal(report.capacity.instances, capacityFrom(report.cost.groups, report.cost.triangles)!.instances)
89
+ })
90
+
91
+ test('투명 재질은 서로 다른 재질로 센다 — 둘이면 한도 1 을 넘고, 그것이 등급을 정한다', () => {
92
+ let asset = withPalette('glass.a', 'glass.b')
93
+ asset = addV3Box(asset, cube('a', 0, 'glass.a', { transparent: true }))
94
+ asset = addV3Box(asset, cube('b', 200, 'glass.b', { transparent: true }))
95
+ const report = v3ReportOf(asset)!
96
+ assert.equal(report.score.budgets.find(b => b.key === 'transparent')!.used, 2)
97
+ assert.equal(report.score.tightest.key, 'transparent')
98
+ assert.equal(report.score.grade, 'D')
99
+
100
+ let same = withPalette('glass.a')
101
+ same = addV3Box(same, cube('a', 0, 'glass.a', { transparent: true }))
102
+ same = addV3Box(same, cube('b', 200, 'glass.a', { transparent: true }))
103
+ assert.equal(v3ReportOf(same)!.score.budgets.find(b => b.key === 'transparent')!.used, 1, '같은 재질은 하나로 나눠 쓴다')
104
+ })
105
+
106
+ test('상태를 따라 움직이는 부품을 센다 — V2 의 「따로 움직이는 부품」', () => {
107
+ let asset = createV3Asset('Probe')
108
+ asset = addV3Box(asset, cube('base', 0))
109
+ asset = addV3Box(asset, cube('arm', 200))
110
+ assert.equal(v3ReportOf(asset)!.score.budgets.find(b => b.key === 'independent')!.used, 0, '전제: 움직임이 없으면 0')
111
+
112
+ asset = applyV3Authoring(asset, {
113
+ kind: 'add-motion',
114
+ part: 'arm',
115
+ motion: { kind: 'turn', axis: 'y' },
116
+ state: { id: 'arm.turn', unit: 'deg', min: 0, max: 90 }
117
+ })
118
+ assert.equal(v3ReportOf(asset)!.score.budgets.find(b => b.key === 'independent')!.used, 1, '도는 팔 하나')
119
+ })
120
+
121
+ test('서로 다른 형상은 things-scene 이 정점 버퍼를 나눠 쓰는 수와 같다', () => {
122
+ let asset = createV3Asset('Probe')
123
+ asset = addV3Box(asset, cube('a', 0))
124
+ asset = addV3Box(asset, cube('b', 200))
125
+ asset = addV3Box(asset, { id: 'c', size: { x: 100, y: 300, z: 100 }, position: { x: 400, y: 150, z: 0 } })
126
+ asset = addV3Primitive(asset, { id: 'd', kind: 'cylinder', dimensions: { radius: 40, length: 100 }, position: { x: 600, y: 50, z: 0 } })
127
+ asset = addV3Primitive(asset, { id: 'e', kind: 'cylinder', dimensions: { radius: 40, length: 100 }, position: { x: 800, y: 50, z: 0 } })
128
+ asset = addV3Primitive(asset, { id: 'f', kind: 'cylinder', dimensions: { radius: 40, length: 100 }, position: { x: 1000, y: 50, z: 0 } })
129
+ // The same cylinder, rounder: another vertex buffer in the renderer, so another shape here.
130
+ asset.appearance.find(look => look.target === 'f')!.segments = 24
131
+ const report = v3ReportOf(asset)!
132
+
133
+ const bodies = compileV3Asset(asset).evaluate().geometry
134
+ const held = bodies.map(body => acquireV3Geometry((body as { shape: never }).shape, (body as { segments?: number }).segments))
135
+ try {
136
+ assert.equal(report.distinctShapes, new Set(held).size, '렌더러의 은행과 같은 키')
137
+ } finally {
138
+ held.forEach(geometry => releaseV3Geometry(geometry))
139
+ }
140
+ assert.equal(report.distinctShapes, 4, '같은 상자 둘 · 키 큰 상자 · 같은 원기둥 둘 · 분할이 다른 원기둥')
141
+ assert.deepEqual(report.reuse, { distinct: 4, parts: 6, saved: 2 })
142
+ assert.equal(report.axes.find(axis => axis.key === 'reuse')!.ratio, 1 - 4 / 6)
143
+ })
144
+
145
+ test('여섯 축 — V2 의 순서, 잴 수 없는 둘은 비율 없이 까닭을 단다', () => {
146
+ let asset = withPalette('palette.accent')
147
+ asset = addV3Box(asset, cube('a', 50))
148
+ asset = addV3Box(asset, cube('b', 150, 'palette.accent'))
149
+ const axes = v3ReportOf(asset)!.axes
150
+
151
+ assert.deepEqual(
152
+ axes.map(axis => axis.key),
153
+ ['scale', 'consistency', 'reuse', 'detail', 'reactive', 'readable']
154
+ )
155
+ const byKey = Object.fromEntries(axes.map(axis => [axis.key, axis]))
156
+ assert.equal(byKey.detail!.ratio, undefined)
157
+ assert.equal(byKey.detail!.blocked, 'figure.text.maturity-blocked-no-detail-level', '디테일 등급은 V3 에 없다(ADR-0087)')
158
+ assert.equal(byKey.reactive!.ratio, undefined)
159
+ assert.equal(byKey.reactive!.blocked, 'figure.text.maturity-blocked-no-binding')
160
+
161
+ assert.equal(byKey.scale!.ratio, 1, `두 부품이면 ${BOARD_ASSETS} 개 목표를 넘긴다`)
162
+ assert.equal(byKey.consistency!.ratio, 1, '재질 둘은 한도 셋 안이다')
163
+ // Two colours, each half of the footprint, no background: entropy ln 2 over the ceiling ln 4.
164
+ assert.equal(byKey.readable!.ratio, Math.log(2) / Math.log(LIMITS.distinctMaterials + 1))
165
+ assert.equal(byKey.readable!.reading, '2')
166
+ })
167
+
168
+ test('재질이 한도를 넘으면 일관성이 한 칸 내려간다 — V2 의 셋 중 하나', () => {
169
+ let asset = withPalette('t1', 't2', 't3')
170
+ asset = addV3Box(asset, cube('a', 0))
171
+ asset = addV3Box(asset, cube('b', 200, 't1'))
172
+ asset = addV3Box(asset, cube('c', 400, 't2'))
173
+ asset = addV3Box(asset, cube('d', 600, 't3'))
174
+ const report = v3ReportOf(asset)!
175
+ assert.equal(report.distinctMaterials, 4)
176
+ const consistency = report.axes.find(axis => axis.key === 'consistency')!
177
+ assert.equal(consistency.ratio, 2 / 3)
178
+ assert.equal(consistency.readingKey, 'figure.text.maturity-consistency-faults')
179
+ })
180
+
181
+ test('한 색이 발자국을 다 덮으면 식별성은 0 이다 — 위에서 보면 한 덩어리', () => {
182
+ let asset = createV3Asset('Probe')
183
+ asset = addV3Box(asset, { id: 'slab', size: { x: 400, y: 20, z: 400 }, position: { x: 0, y: 10, z: 0 } })
184
+ asset = addV3Box(asset, { id: 'under', size: { x: 100, y: 5, z: 100 }, position: { x: 0, y: 2.5, z: 0 } })
185
+ assert.equal(v3ReportOf(asset)!.axes.find(axis => axis.key === 'readable')!.ratio, 0, '아래 부품은 가려진다')
186
+ })
187
+
188
+ test('추이 — 1 에서 1만까지 1·2·5, 삼각형과 draw call 은 오르고 정점 버퍼와 재질은 평평하다', () => {
189
+ assert.deepEqual(decadeSteps(1, 100), [1, 2, 5, 10, 20, 50, 100])
190
+ let asset = createV3Asset('Probe')
191
+ asset = addV3Box(asset, cube('a', 0))
192
+ asset = addV3Box(asset, cube('b', 200))
193
+ const report = v3ReportOf(asset)!
194
+ const last = report.curve[report.curve.length - 1]!
195
+ assert.equal(report.curve[0]!.instances, 1)
196
+ assert.equal(last.instances, 10000)
197
+ assert.equal(last.triangles, report.cost.triangles * 10000)
198
+ assert.equal(last.drawCalls, report.cost.groups * 10000)
199
+ assert.ok(report.curve.every(p => p.geometries === report.distinctShapes && p.materials === report.distinctMaterials))
200
+
201
+ const layout = curveLayout(report.curve)!
202
+ assert.deepEqual(layout.lines.map(line => [line.key, line.flat, line.dashed]), [
203
+ ['triangles', false, false],
204
+ ['drawCalls', false, false],
205
+ ['geometries', true, false],
206
+ ['materials', true, true]
207
+ ])
208
+ assert.equal(layout.overlap, 1, '형상 하나 · 재질 하나 — 평평한 선이 겹친다고 적는다')
209
+ assert.deepEqual(layout.xTicks.map(t => t.label), ['1', '10', '100', '1k', '10k'])
210
+ })
211
+
212
+ test('화면은 V2 리포트의 차례로 그린다 — 개수 · 축 · 한도 · 그리는 비용 · 추이 · 발행 판정', () => {
213
+ const order = ['this.renderVerdict(report)', 'this.renderAxes(report)', 'this.renderBudgets(report)', 'this.renderMetrics(report)', 'this.renderCurve()', 'this.renderGate()']
214
+ // From the full report, past the early return that shows only guidance and the release verdict.
215
+ const at = order.map(call => verdict.indexOf(call, verdict.indexOf('${this.renderVerdict(report)}')))
216
+ assert.ok(at.every(i => i > 0), `모든 절을 그린다: ${order.filter((_, i) => at[i] < 0)}`)
217
+ assert.deepEqual([...at].sort((a, b) => a - b), at)
218
+ assert.ok(verdict.includes('...GRADE_SCALE.map('), '눈금은 등급을 셈한 경계에서 그린다 — 따로 적으면 어긋난다')
219
+ assert.ok(verdict.includes('SidePanelStyles'), '옆 칸의 한 모양을 쓴다')
220
+ })
221
+
222
+ test('radar 와 추이 그림의 조각은 svg 템플릿이다 — html 로 만들면 소리 없이 안 그려진다', () => {
223
+ for (const block of verdict.match(/svg`[\s\S]*?<\/svg>/g) ?? []) {
224
+ assert.doesNotMatch(block, /html`\s*<\s*(polygon|line|circle|rect|path|text|g)\b/)
225
+ }
226
+ assert.ok((verdict.match(/svg`/g) ?? []).length >= 8, '전제: 조각을 센다')
227
+ })
228
+
229
+ test('화면이 키를 이어 붙여 찾는 말도 다섯 말 모두에 있다', () => {
230
+ const keys = [
231
+ ...V3_BUDGET_KEYS.map(key => `figure.label.budget-${key}`),
232
+ ...['scale', 'consistency', 'reuse', 'detail', 'reactive', 'readable'].flatMap(key => [`figure.label.axis-${key}`, `figure.label.axis-${key}-short`]),
233
+ 'figure.text.bound-by-groups',
234
+ 'figure.text.bound-by-triangles',
235
+ ...[...maturity.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!)
236
+ ]
237
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
238
+ const table = JSON.parse(read(`../translations/${lang}.json`))
239
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
240
+ }
241
+ })
242
+
243
+ test('부품 목록 머리의 값 한 줄 — 삼각형 · 묶음 · 한 화면 몇 대 · 등급, D 면 넘친 한도를 적는다', () => {
244
+ assert.equal(budgetOf(undefined), undefined, '잴 것이 없으면 줄도 없다')
245
+ let asset = createV3Asset('Probe')
246
+ asset = addV3Box(asset, cube('a', 0))
247
+ const line = budgetOf(v3ReportOf(asset))!
248
+ assert.deepEqual(
249
+ { triangles: line.triangles, groups: line.groups, grade: line.grade },
250
+ { triangles: 12, groups: 1, grade: 'A' }
251
+ )
252
+ assert.equal(line.instances, capacityFrom(1, 12)!.instances)
253
+
254
+ let glass = withPalette('glass.a', 'glass.b')
255
+ glass = addV3Box(glass, cube('a', 0, 'glass.a', { transparent: true }))
256
+ glass = addV3Box(glass, cube('b', 200, 'glass.b', { transparent: true }))
257
+ const over = budgetOf(v3ReportOf(glass))!
258
+ assert.equal(over.grade, 'D')
259
+ assert.deepEqual(over.tightest, { key: 'transparent', used: 2, limit: 1 })
260
+
261
+ const parts = read('../client/modeller/figure-v3-parts.ts')
262
+ const drawn = parts.slice(parts.indexOf(' private renderBudget()'), parts.indexOf(' /** 한꺼번에 감추고'))
263
+ assert.ok(drawn.includes("const over = b.grade === 'D'") && drawn.includes("'figure.text.parts-budget-over'"), 'D 에서 넘친 한도를 적는다')
264
+ assert.ok(parts.includes("new CustomEvent('show-maturity'"), '누르면 성숙도로')
265
+ const editor = read('../client/modeller/v3-editor.ts')
266
+ assert.ok(editor.includes('.budget=${this.budget}') && editor.includes("@show-maturity=${() => (this.tab = 'verdict')}"))
267
+ assert.ok(verdict.includes('budget: budgetOf(report)'), '성숙도 탭이 같은 보고서에서 알린다')
268
+ })
@@ -0,0 +1,22 @@
1
+ /*
2
+ * **팔레트 아이콘은 글꼴에 있는 이름만** (사용자 2026-09-24: 「is this icon proper?」 — 벽 칸에 「AL」이 떴다).
3
+ *
4
+ * `md-icon` 은 이름을 합자로 그린다. 글꼴에 없는 이름은 글자 그대로 나오고, 칸에 잘려 「wall」이 「AL」로 보였다.
5
+ * node 에서는 글꼴을 볼 수 없어, :3300 의 글꼴에서 한 칸 폭(24px)으로 그려지는 것을 잰 이름만 여기에 둔다. 새 아이콘은
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
+ /** Measured in the app's icon font on :3300, 2026-09-24: each draws one cell wide. `wall` drew 52px -- its letters. */
14
+ const RENDERS = new Set(['deployed_code', 'database', 'crop_square', 'circle', 'change_history', 'hexagon', 'check_box_outline_blank', 'crop_portrait', 'door_front'])
15
+
16
+ test('every shape in the palette has an icon the font draws', () => {
17
+ const parts = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-parts.ts', import.meta.url)), 'utf8')
18
+ const recipes = parts.slice(parts.indexOf('const RECIPES'), parts.indexOf(']', parts.indexOf('const RECIPES')))
19
+ const icons = [...recipes.matchAll(/icon: '([a-z_]+)'/g)].map(m => m[1]!)
20
+ assert.ok(icons.length >= 9)
21
+ assert.deepEqual(icons.filter(icon => !RENDERS.has(icon)), [])
22
+ })
@@ -29,7 +29,9 @@ test('복제 · 순서 · 삭제는 figure-model 의 부품 편집으로, 세션
29
29
  })
30
30
 
31
31
  test('작업대에서 가린 부품은 캔버스에서 빠지고, 다른 도형을 열면 풀린다', () => {
32
- assert.match(editor, /drawableV3Parts\(view\.geometry \?\? \[\]\)\.filter\(body => !this\.hiddenParts\.has/)
32
+ assert.ok(editor.includes('.parts=${this.drawnParts(view.geometry, asset)}'))
33
+ const drawn = editor.slice(editor.indexOf(' private drawnParts('), editor.indexOf(' private hold('))
34
+ assert.ok(drawn.includes('parts.filter(body => !this.hiddenParts.has('), '끄는 값을 그릴 때도 가린 부품은 빠진다')
33
35
  const reset = editor.slice(editor.indexOf('protected willUpdate'), editor.indexOf('private run('))
34
36
  assert.match(reset, /this\.hiddenParts = new Set\(\)/)
35
37
  })
@@ -0,0 +1,42 @@
1
+ /*
2
+ * **미리보기 — V2 의 그것** (사용자 2026-09-24: 「미리보기도 v2 가 낫다」). 저작 중인 도형을 도면처럼 등록하고,
3
+ * 기준 크기와 한 · 두 · 세 축을 2배로 늘린 일곱을 나란히 FigureInstance 로 세운다.
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 { addV3Box, createV3Asset } from '@hatiolab/figure-model'
11
+
12
+ import { authoredSizeOf, PREVIEW_WAYS, previewInstances } from '../client/modeller/v3-preview-model.ts'
13
+
14
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
+
16
+ test('여덟 — 기준과 일곱 조합, 한 줄에 넷, 늘린 축만 두 배 · 높이는 늘 적는다', () => {
17
+ assert.deepEqual(PREVIEW_WAYS.map(w => w.name), ['base', 'x', 'y', 'z', 'x·y', 'x·z', 'y·z', 'x·y·z'])
18
+ const { components, board } = previewInstances('T', { x: 1000, y: 500, z: 400 })
19
+ assert.equal(components.length, 8)
20
+ assert.deepEqual(components.map(c => [c.width, c.depth, c.height]), [
21
+ [1000, 500, 400], [2000, 500, 400], [1000, 1000, 400], [1000, 500, 800],
22
+ [2000, 1000, 400], [2000, 500, 800], [1000, 1000, 800], [2000, 1000, 800]
23
+ ])
24
+ assert.ok(components.every(c => c.type === 'T'))
25
+ assert.deepEqual(board, { width: 10000, height: 2000 }, '가장 큰 것이 들어갈 칸 넷씩 두 줄')
26
+ })
27
+
28
+ test('크기를 선언하지 않은 도형은 부품이 차지하는 범위를 기준 크기로', () => {
29
+ const asset = addV3Box(createV3Asset('Fixed'), { id: 'body', size: { x: 300, y: 200, z: 100 }, position: { x: 0, y: 100, z: 0 } })
30
+ assert.deepEqual(authoredSizeOf(asset), { x: 300, y: 200, z: 100 })
31
+ })
32
+
33
+ test('편집 | 미리보기 — 띠에서 오가고, 미리보기는 도면처럼 등록해 세우며 제 타입으로 남의 도면을 건드리지 않는다', () => {
34
+ const editor = read('../client/modeller/v3-editor.ts')
35
+ assert.ok(editor.includes("(['edit', 'preview'] as const).map("))
36
+ assert.ok(editor.includes('<figure-v3-preview'))
37
+ const preview = read('../client/modeller/figure-v3-preview.ts')
38
+ assert.ok(preview.includes('registerFigureV3(type, asset)') && preview.includes('unregisterFigureV3(this.registered)'))
39
+ assert.ok(preview.includes('`figure-v3-preview-${this.context || \'new\'}`'))
40
+ assert.ok(preview.includes("kind: 'set-state-defaults'"), '이 자세를 기본으로')
41
+ assert.ok(preview.includes('dialShape('), '각도는 다이얼로')
42
+ })
@@ -61,8 +61,10 @@ test('태그 줄은 V2 와 같은 규칙으로 목록이 된다', () => {
61
61
  const page = read('../client/pages/figure-v3-modeller-page.ts')
62
62
 
63
63
  test('화면이 속성 탭을 걸고, 명령은 세션의 이력 하나로, 분류 · 태그는 행으로 간다', () => {
64
- assert.ok(/renderTab\('properties', 'tune'/.test(page), '속성 탭')
65
- assert.ok(/<figure-v3-properties\s+\?off=/.test(page), '감추되 떼지 않는다')
64
+ const editor = read('../client/modeller/v3-editor.ts')
65
+ assert.ok(/renderTab\('properties', 'tune'/.test(editor), '속성 탭은 편집기의 오른쪽 칸에')
66
+ assert.ok(/<figure-v3-properties\s+slot="figure"/.test(page), '도형 속성은 부품을 고르지 않았을 때 속성 탭에 선다')
67
+ assert.ok(editor.includes('<slot name="figure"></slot>'))
66
68
  assert.ok(/@v3-command=\$\{this\.onCommand\}/.test(page) && /@v3-catalog=\$\{this\.onCatalog\}/.test(page))
67
69
  const run = page.slice(page.indexOf('private runCommand'), page.indexOf('private onCatalog'))
68
70
  assert.ok(/this\.session\.command\(actionOf\(\)\)/.test(run), '명령은 세션을 지난다 — 되돌리기가 먹는다')