@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 저작 화면의 **보기 설정**.
3
+ *
4
+ * ## 저장되지 않는다
5
+ *
6
+ * 이것은 자산이 아니라 **작업대**다. 조명을 밝게 하고 바닥을 켠 것은 그 사람이 지금
7
+ * 보기 편한 상태일 뿐, 그 자산의 성질이 아니다. 그래서 `FigureSource` 에 안 들어간다 —
8
+ * `toFigureSource` 는 판에서 `width` · `height` · `depth` 와 몇 가지만 집어 가고 나머지는
9
+ * 버린다.
10
+ *
11
+ * 다른 사람이 같은 자산을 열면 자기 설정으로 본다. 그것이 맞다.
12
+ *
13
+ * ## 씬이 아는 이름을 그대로 쓴다
14
+ *
15
+ * 여기 이름은 전부 `model-layer` 의 상태 이름이다. 옮겨 적는 표를 두지 않는다 — 표를
16
+ * 두면 씬이 새 설정을 받아도 이쪽이 모른다.
17
+ *
18
+ * 값이 바뀌면 씬이 스스로 반영한다(`three-capability` 의 `onchange`). 다만 `grid` 는
19
+ * 한동안 3D 를 세울 때만 읽혔다 — things-scene 10.1.5 에서 고쳤다.
20
+ */
21
+ export interface ViewSettings {
22
+ /** 좌표 바탕(축 가이드). 원점과 눈금을 보인다. */
23
+ grid: boolean
24
+ /** 축 이름표(X · Y · Z). 눈금만 두고 글자를 끌 수 있다. */
25
+ gridLabels: boolean
26
+ /** 판의 바닥면. 좌표 바탕과 겹치므로 기본은 끈다. */
27
+ floor: boolean
28
+ /** Environment — 반사(IBL). 금속 재질이 무엇을 비출지 정한다. */
29
+ environment: EnvironmentName
30
+ /** Sky Color — 배경색. */
31
+ skyColor: string
32
+ /** Key Light Intensity. */
33
+ dirLightIntensity: number
34
+ /** Key Light — Follow Camera. 끄면 그림자 방향이 고정된다. */
35
+ dirLightFollowCamera: boolean
36
+ /** Key Light — Cast Shadow. */
37
+ dirShadowEnabled: boolean
38
+ /** Hemisphere Light Intensity — 그늘진 면을 들어올린다. */
39
+ hemiIntensity: number
40
+ }
41
+
42
+ /**
43
+ * 환경 프리셋 — **`scene-manager` 의 `SkyPreset` 과 같은 여덟이다.**
44
+ *
45
+ * 한동안 다섯만 적었다. 씬은 여덟을 다 받는데 이쪽이 셋을 몰라서 고를 수 없었다.
46
+ * operato-board 의 3D 씬 속성 패널도 같은 여덟을 보인다.
47
+ */
48
+ export const ENVIRONMENTS = [
49
+ 'studio',
50
+ 'warehouse',
51
+ 'factory',
52
+ 'office',
53
+ 'home',
54
+ 'sunny',
55
+ 'cloudy',
56
+ 'rainy'
57
+ ] as const
58
+ export type EnvironmentName = (typeof ENVIRONMENTS)[number]
59
+
60
+ /** 배경색 기본값. 회색이라 부품 색이 제 색으로 보인다. */
61
+ export const WORKBENCH = '#7c7c7c'
62
+
63
+ /**
64
+ * 처음 값.
65
+ *
66
+ * **지금 저작 화면이 실제로 쓰던 값 그대로다.** 설정을 만들면서 화면이 달라지면
67
+ * 무엇이 설정 때문이고 무엇이 원래 그랬는지 갈리지 않는다.
68
+ */
69
+ export const DEFAULT_VIEW: ViewSettings = {
70
+ grid: true,
71
+ gridLabels: true,
72
+ floor: false,
73
+ environment: 'studio',
74
+ skyColor: WORKBENCH,
75
+ dirLightIntensity: 2.0,
76
+ dirLightFollowCamera: true,
77
+ dirShadowEnabled: true,
78
+ hemiIntensity: 4.0
79
+ }
@@ -0,0 +1,288 @@
1
+ import { LIMITS, PART_LIMIT, SEGMENT_PRESETS, materialKeyOf } from '@hatiolab/figure-model'
2
+ import type { DetailLevel, FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'
3
+
4
+ import { capacityFrom } from './scene-budget.js'
5
+
6
+ /**
7
+ * 성숙도 — 이 형상이 얼마나 잘 만들어졌나.
8
+ *
9
+ * ## 축이 여럿이어야 하는 이유
10
+ *
11
+ * 「몇 개까지 편한가」 하나로는 좋은 형상과 나쁜 형상이 구분되지 않는다. 부품 하나짜리
12
+ * 상자도 그 축에서는 만점이다. 잘 만들어졌다는 것은 **여러 물음에 각각 답하는 것**이고,
13
+ * 그것을 한 숫자로 뭉개면 무엇을 고쳐야 할지 알 수 없다.
14
+ *
15
+ * ## 축을 지어내지 않았다
16
+ *
17
+ * `docs/design/component-authoring-tool.md` 의 「인정받을 수 있는 최소 품질 — 기준선」
18
+ * 다섯이 이미 있다. 그것을 잴 수 있는 형태로 옮기고, 이미 셈이 있는 것(규모·되쓰기)을
19
+ * 더했다.
20
+ *
21
+ * 기준선 1 실루엣으로 알아본다 → readable **아직 측정하지 못한다**
22
+ * 기준선 2 상태가 색으로 구분된다 → reactive **아직 측정하지 못한다**
23
+ * 기준선 3 다른 자산과 한 세트로 보인다 → consistency
24
+ * 기준선 4 확대해도 안 깨진다 → consistency (텍스처 0)
25
+ * 기준선 5 라벨이 읽힌다 → reactive
26
+ *
27
+ * ## 못 재는 축을 지어내지 않는다
28
+ *
29
+ * 두 축은 지금 잴 수 없다. 하나는 그리기가 필요하고(실루엣), 하나는 형식에 길이
30
+ * 없다(상태 바인딩 — figure-model#2). **그것을 0점으로도 만점으로도 두지 않는다.**
31
+ * 「아직 측정하지 못한다」고 알리고 이유를 붙인다. 측정하지 못한 것을 점수로 만들면 그 점수가 거짓말을
32
+ * 한다.
33
+ *
34
+ * ## 이 파일은 임시다
35
+ *
36
+ * 셈은 figure-model 것이어야 한다 — 거기가 `costOf` · `scoreOf` 를 갖고 있다.
37
+ * 형식 쪽 결정(#2 · #4)이 서면 이 파일을 지우고 그쪽으로 옮긴다.
38
+ */
39
+
40
+ export type AxisKey = 'scale' | 'consistency' | 'reuse' | 'detail' | 'reactive' | 'readable'
41
+
42
+ export interface MaturityAxis {
43
+ key: AxisKey
44
+ /** 0~1. 못 재면 없다. */
45
+ ratio?: number
46
+ /** 지금 이 축의 값. 그대로 보여 준다. */
47
+ reading?: string
48
+ /** 값이 숫자가 아니라 말일 때의 문구 키. `reading` 과 함께 쓰지 않는다. */
49
+ readingKey?: string
50
+ /** 왜 이 값인가. */
51
+ because?: string
52
+ /** 못 재면 그 이유. 있으면 `ratio` 가 없다. */
53
+ blocked?: string
54
+ }
55
+
56
+ /** 한 도면에 이만큼 놓는 것을 본다. 설계 문서 §4.15 의 「자산 500 개」. */
57
+ const BOARD_ASSETS = 500
58
+
59
+ /**
60
+ * 여섯 축을 측정한다.
61
+ *
62
+ * 한 숫자로 합치지 않는다. 합치면 「무엇을 고쳐야 하나」가 사라진다 — 그것이 이
63
+ * 리포트의 존재 이유다.
64
+ */
65
+ export function maturityOf(source: FigureSource, cost: FigureCost, score: FigureScore): MaturityAxis[] {
66
+ return [scale(cost), consistency(source, cost), reuse(score), detail(source, cost), reactive(source), readable(source, cost)]
67
+ }
68
+
69
+ /** 규모 — 한 도면에 얼마나 놓을 수 있나. */
70
+ function scale(cost: FigureCost): MaturityAxis {
71
+ const capacity = capacityFrom(cost.groups, cost.triangles)
72
+ if (!capacity) {
73
+ return { key: 'scale', blocked: 'text.maturity-blocked-no-parts' }
74
+ }
75
+
76
+ return {
77
+ key: 'scale',
78
+ // 한 도면 목표치(500)를 채우면 1 이다. 그보다 많이 놓을 수 있어도 1 을 넘기지 않는다 —
79
+ // 목표를 넘긴 여유는 다른 축을 고치는 데 쓰는 편이 낫다.
80
+ ratio: Math.min(1, capacity.instances / BOARD_ASSETS),
81
+ reading: String(capacity.instances),
82
+ because: 'text.maturity-scale-because'
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 한 세트로 보이나. 기준선 3·4.
88
+ *
89
+ * 셋을 본다. 팔레트 밖 표면이 없나(텍스처 0 — 확대해도 안 깨진다), 분할 수가
90
+ * 프리셋인가, 투명 재질을 아끼나. 셋 다 이미 형식이 위반으로 세고 있는 것들이다.
91
+ */
92
+ function consistency(source: FigureSource, cost: FigureCost): MaturityAxis {
93
+ const offPreset = source.parts.filter(
94
+ part => part.segments !== undefined && !SEGMENT_PRESETS.includes(part.segments)
95
+ ).length
96
+
97
+ const faults =
98
+ (cost.surfaceTextures > 0 ? 1 : 0) +
99
+ (offPreset > 0 ? 1 : 0) +
100
+ (cost.distinctMaterials > LIMITS.materialGroups ? 1 : 0)
101
+
102
+ return {
103
+ key: 'consistency',
104
+ ratio: (3 - faults) / 3,
105
+ readingKey: faults === 0 ? 'text.maturity-consistency-clean' : 'text.maturity-consistency-faults',
106
+ because: 'text.maturity-consistency-because'
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 되쓰나 — 같은 형상을 몇 번 다시 쓰나.
112
+ *
113
+ * `scoreOf` 가 이미 재고 있으나 **점수에는 넣지 않는다**고 선언돼 있다(한도가 아니라
114
+ * 덤이다). 축으로는 뜻이 있다 — 되쓰기가 많은 형상이 잘 짜인 형상이다. 다만 부품마다
115
+ * 형상이 달라도 잘못이 아니므로 **낮다고 나무라지 않는다.**
116
+ */
117
+ function reuse(score: FigureScore): MaturityAxis {
118
+ const { distinct, parts, saved } = score.reuse
119
+ if (parts === 0) {
120
+ return { key: 'reuse', blocked: 'text.maturity-blocked-no-parts' }
121
+ }
122
+
123
+ return {
124
+ key: 'reuse',
125
+ // ratio 가 낮을수록 많이 되쓴다 — 뒤집어서 높은 쪽이 좋게 만든다
126
+ ratio: 1 - distinct / parts,
127
+ reading: String(saved),
128
+ because: 'text.maturity-reuse-because'
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 정밀도 — 밝힌 등급에 걸맞게 만들어졌나.
134
+ *
135
+ * 등급(S·M·L)은 「어느 거리에서 읽히도록 만든 것인가」를 밝히는 것이다. 그런데 등급만
136
+ * 올려 놓고 부품 셋으로 끝내면 그 약속을 안 지킨 것이다. **한도를 얼마나 썼나**로 본다.
137
+ *
138
+ * 이 축은 「많이 쓸수록 좋다」가 아니다. 등급에 맞게 채웠나를 본다 — 그래서 넘치면
139
+ * 다시 내려간다.
140
+ */
141
+ function detail(source: FigureSource, cost: FigureCost): MaturityAxis {
142
+ const level = (source.detailLevel ?? 'M') as DetailLevel
143
+ const limit = PART_LIMIT[level]
144
+ const used = cost.parts / limit
145
+
146
+ return {
147
+ key: 'detail',
148
+ // 절반쯤 쓴 것을 제일 좋게 본다. 텅 비어도, 넘쳐도 등급과 안 맞는다.
149
+ ratio: used > 1 ? Math.max(0, 2 - used) : Math.min(1, used * 2),
150
+ reading: `${cost.parts} / ${limit}`,
151
+ because: 'text.maturity-detail-because'
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 살아 있나 — 기준선 2·5. 상태가 색으로 구분되고 라벨이 읽히나.
157
+ *
158
+ * **지금 잴 수 없다.** 형식에 `slot` 이 있지만 그리는 쪽이 읽지 않고(선언 한 줄이
159
+ * 전부다), 상태를 부품에 잇는 길 자체가 아직 정해지지 않았다(figure-model#2).
160
+ *
161
+ * 0 점으로 두지 않는다. 0 은 「이 형상이 상태를 안 보여 준다」는 뜻이 되는데, 사실은
162
+ * **아무 형상도 보여 줄 수 없는 상태**다. 축 자체가 아직 없는 것이다.
163
+ */
164
+ function reactive(source: FigureSource): MaturityAxis {
165
+ return { key: 'reactive', blocked: 'text.maturity-blocked-no-binding' }
166
+ }
167
+
168
+ /**
169
+ * 식별성 — 기준선 1. 위에서 볼 때 무엇인지 알아보나.
170
+ *
171
+ * ## 시점 엔트로피를 빌린다
172
+ *
173
+ * 「어느 시점에서 얼마나 알아볼 수 있나」는 이미 연구된 물음이다. Vázquez 등의
174
+ * **viewpoint entropy** 는 시점에서 보이는 각 면의 투영 면적 비율로 섀넌 엔트로피를
175
+ * 낸다. 모든 면이 같은 면적으로 보일 때가 최대이고, 한 면이 화면을 다 덮으면 0 이다.
176
+ *
177
+ * H = -Σ (Ai / At) · log(Ai / At)
178
+ *
179
+ * ## 다만 면으로 세지 않는다
180
+ *
181
+ * 그 문헌이 스스로 밝히는 한계가 **폴리곤 분할에 좌우된다**는 것이다 — 같은 형상도
182
+ * 잘게 쪼개면 엔트로피가 올라간다. 우리에게는 그 한계가 치명적이다. 분할 수를 8 에서
183
+ * 16 으로 올리는 것은 형상을 알아보기 쉽게 만드는 일이 아닌데, 면으로 세면 점수가
184
+ * 오른다.
185
+ *
186
+ * 그래서 **면이 아니라 색 구역으로 센다.** 눈이 구별하는 것은 삼각형이 아니라 색이
187
+ * 다른 덩어리다. 같은 토큰을 쓰는 부품 둘이 붙어 있으면 눈에는 한 덩어리다.
188
+ * 이렇게 하면 분할 수에 좌우되지 않으면서 원래 물음에 답한다.
189
+ *
190
+ * ## 기준값도 지어내지 않는다
191
+ *
192
+ * 정규화 분모를 `log(재질 group 한도 + 1)` 로 둔다. 이 형식이 허용하는 재질 group 이
193
+ * 셋이고 거기에 배경을 더하면 넷이므로, **이 형식에서 도달 가능한 최대**가 곧
194
+ * 기준이다. 임의로 고른 수가 아니다.
195
+ *
196
+ * ## 그리지 않고 셈으로 낸다
197
+ *
198
+ * 저작 중에 매번 렌더링할 수 없다. 대신 부품의 발자국(x·y·너비·높이)을 위에서
199
+ * 겹쳐 본다 — 위에 있는 것이 아래를 가린다. 축에 정렬된 상자이므로 격자에 찍어
200
+ * 세면 된다.
201
+ *
202
+ * 이 근사가 실제 렌더링과 맞는지는 하네스가 따로 확인한다
203
+ * (`test/browser/check-silhouette.mjs`).
204
+ */
205
+ function readable(source: FigureSource, cost: FigureCost): MaturityAxis {
206
+ const regions = topViewRegions(source)
207
+ if (regions.total === 0) {
208
+ return { key: 'readable', blocked: 'text.maturity-blocked-no-parts' }
209
+ }
210
+
211
+ // 배경도 한 구역으로 센다 — 발자국을 얼마나 채우나가 식별성의 일부다.
212
+ // Vázquez 의 「열린 장면」 형태와 같다(최대가 log(면 수 + 1)).
213
+ const shares = [...regions.byMaterial.values(), regions.background]
214
+ .map(area => area / regions.footprint)
215
+ .filter(share => share > 0)
216
+
217
+ const entropy = -shares.reduce((sum, p) => sum + p * Math.log(p), 0)
218
+ const ceiling = Math.log(LIMITS.materialGroups + 1)
219
+
220
+ return {
221
+ key: 'readable',
222
+ ratio: Math.min(1, entropy / ceiling),
223
+ reading: `${regions.byMaterial.size}`,
224
+ because: 'text.maturity-readable-because'
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 위에서 내려다볼 때 어느 재질이 얼마나 보이나.
230
+ *
231
+ * 부품 발자국을 격자에 찍되 **높은 것이 이긴다.** 축에 정렬된 상자만 다루므로
232
+ * 이것으로 충분하다 — 회전한 부품은 발자국을 크게 잡아 보수적으로 본다.
233
+ */
234
+ function topViewRegions(source: FigureSource) {
235
+ const GRID = 64
236
+ const cells = new Array<string | null>(GRID * GRID).fill(null)
237
+ const height = new Float32Array(GRID * GRID)
238
+
239
+ // 톱뷰의 두 축은 x 와 z 다. y 는 높이라 위에서 보면 보이지 않는다.
240
+ // 원점은 기준 상자의 중심이므로 격자의 가운데가 0 이다.
241
+ const sx = GRID / source.base.x
242
+ const sz = GRID / source.base.z
243
+
244
+ for (const part of source.parts) {
245
+ const { position, size } = part.transform
246
+ const top = position.y + size.y / 2
247
+ const key = materialKeyOf(part)
248
+
249
+ const x0 = Math.max(0, Math.floor((position.x - size.x / 2 + source.base.x / 2) * sx))
250
+ const x1 = Math.min(GRID, Math.ceil((position.x + size.x / 2 + source.base.x / 2) * sx))
251
+ const y0 = Math.max(0, Math.floor((position.z - size.z / 2 + source.base.z / 2) * sz))
252
+ const y1 = Math.min(GRID, Math.ceil((position.z + size.z / 2 + source.base.z / 2) * sz))
253
+
254
+ for (let y = y0; y < y1; y++) {
255
+ for (let x = x0; x < x1; x++) {
256
+ // 원기둥·구는 발자국 상자를 다 채우지 않는다. 모서리를 깎아 근사한다.
257
+ if (ROUND_FOOTPRINT.includes(part.primitive)) {
258
+ const cxp = (x + 0.5 - (x0 + x1) / 2) / ((x1 - x0) / 2 || 1)
259
+ const cyp = (y + 0.5 - (y0 + y1) / 2) / ((y1 - y0) / 2 || 1)
260
+ if (cxp * cxp + cyp * cyp > 1) continue
261
+ }
262
+ const i = y * GRID + x
263
+ if (cells[i] === null || top > height[i]) {
264
+ cells[i] = key
265
+ height[i] = top
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ const byMaterial = new Map<string, number>()
272
+ let covered = 0
273
+ for (const key of cells) {
274
+ if (key === null) continue
275
+ covered++
276
+ byMaterial.set(key, (byMaterial.get(key) ?? 0) + 1)
277
+ }
278
+
279
+ return {
280
+ byMaterial,
281
+ total: covered,
282
+ background: GRID * GRID - covered,
283
+ footprint: GRID * GRID
284
+ }
285
+ }
286
+
287
+ /** 위에서 보면 발자국 상자를 다 채우지 않는 도형. */
288
+ const ROUND_FOOTPRINT: string[] = ['cylinder', 'sphere']
@@ -0,0 +1,252 @@
1
+ import {
2
+ addPart,
3
+ alignPartToEnd,
4
+ alignPartToStart,
5
+ canMirror,
6
+ centerPart,
7
+ duplicatePart,
8
+ gridStep,
9
+ mirrorPart,
10
+ movePart,
11
+ newPart,
12
+ niceStep,
13
+ partLimit,
14
+ removePart,
15
+ snapPart,
16
+ updatePart
17
+ } from './part-edits'
18
+ import { toFigureSource } from './figure-source'
19
+ import type { BoardModel, PartModel } from './figure-source'
20
+ import { validate } from '@hatiolab/figure-model'
21
+
22
+ /**
23
+ * 씬 모델 용어로 부품을 편집하는 규칙.
24
+ *
25
+ * 편집 중의 기준 데이터는 씬 모델이므로 편집도 씬의 용어로 한다. 그런데 저장되는 것은
26
+ * FigureSource 다. 그래서 두 가지를 확인한다 — **씬 용어에서 규칙이 맞는지**, 그리고
27
+ * **변환했을 때 검증을 통과하는지**. 앞의 것만 보면 화면에서는 멀쩡한데 저장이 거부되는
28
+ * 상황이 생긴다.
29
+ */
30
+
31
+ /** 보드 3000 x 2000 x 800 — 세 축이 모두 달라야 축을 바꿔 써도 테스트가 잡아낸다. */
32
+ const BOARD: BoardModel = {
33
+ width: 3000,
34
+ height: 2000,
35
+ depth: 800,
36
+ figureType: 'AGV',
37
+ detailLevel: 'L'
38
+ }
39
+
40
+ const DECK: PartModel = {
41
+ type: 'figure-part',
42
+ name: 'deck',
43
+ primitive: 'cube',
44
+ left: 1200,
45
+ top: 900,
46
+ width: 800,
47
+ height: 600,
48
+ zPos: 500,
49
+ depth: 200,
50
+ token: 'palette.primary'
51
+ }
52
+
53
+ describe('부품 생성', () => {
54
+ it('보드의 3분의 1 크기로 생성된다 — 추가하자마자 보여야 한다', () => {
55
+ const part = newPart(BOARD, [], 'cube')
56
+
57
+ expect(part.width).toBe(1000)
58
+ expect(part.height).toBe(667)
59
+ expect(part.depth).toBe(267)
60
+ })
61
+
62
+ it('바닥 가운데에 놓인다', () => {
63
+ const part = newPart(BOARD, [], 'cube')
64
+
65
+ expect(part.zPos).toBe(0)
66
+ // 위치도 크기도 정수로 맞추므로 0.5 까지는 어긋난다
67
+ expect(Math.abs(part.left + part.width / 2 - BOARD.width / 2)).toBeLessThanOrEqual(0.5)
68
+ expect(Math.abs(part.top + part.height / 2 - BOARD.height / 2)).toBeLessThanOrEqual(0.5)
69
+ })
70
+
71
+ it('보드에 그대로 놓을 수 있는 씬 컴포넌트 타입을 갖는다', () => {
72
+ expect(newPart(BOARD, [], 'cube').type).toBe('figure-part')
73
+ })
74
+
75
+ it('이름이 중복되지 않는다 — 저장되는 식별자다', () => {
76
+ let parts: PartModel[] = []
77
+ for (let i = 0; i < 3; i++) parts = addPart(BOARD, parts, 'cube')
78
+
79
+ expect(parts.map(part => part.name)).toEqual(['cube', 'cube-2', 'cube-3'])
80
+ })
81
+
82
+ it('팔레트 토큰을 기존 부품에서 물려받는다 — 토큰이 늘면 재질 그룹이 하나씩 는다', () => {
83
+ const parts = addPart(BOARD, [{ ...DECK, token: 'palette.dark' }], 'sphere')
84
+ expect(parts[1].token).toBe('palette.dark')
85
+ })
86
+
87
+ it('곡면 도형은 분할 수를 갖고 생성된다', () => {
88
+ expect(newPart(BOARD, [], 'cylinder').segments).toBe(12)
89
+ expect(newPart(BOARD, [], 'cube').segments).toBeUndefined()
90
+ })
91
+
92
+ it('polygon 은 삼각형으로 생성된다 — 점이 3개보다 적으면 검증에서 막힌다', () => {
93
+ expect(newPart(BOARD, [], 'polygon').path).toHaveLength(3)
94
+ })
95
+
96
+ /*
97
+ 화면에서 멀쩡해 보여도 저장이 거부되면 사용자는 이유를 알 수 없다. 새로 만든 부품은
98
+ 손대지 않은 상태로도 검증을 통과해야 한다.
99
+ */
100
+ it('새로 만든 부품만으로도 검증을 통과한다', () => {
101
+ for (const kind of ['cube', 'wall', 'cylinder', 'sphere', 'rect', 'polygon'] as const) {
102
+ const source = toFigureSource(BOARD, [newPart(BOARD, [], kind)])
103
+ expect(validate(source).errors).toEqual([])
104
+ }
105
+ })
106
+ })
107
+
108
+ describe('목록 편집', () => {
109
+ const PARTS = [DECK, { ...DECK, name: 'mast' }, { ...DECK, name: 'wheel' }]
110
+
111
+ it('삭제한 것만 빠진다', () => {
112
+ expect(removePart(PARTS, 1).map(part => part.name)).toEqual(['deck', 'wheel'])
113
+ })
114
+
115
+ it('복제본이 바로 뒤에 오고 이름이 새로 붙는다', () => {
116
+ const next = duplicatePart(PARTS, 0)
117
+ expect(next.map(part => part.name)).toEqual(['deck', 'deck-copy', 'mast', 'wheel'])
118
+ })
119
+
120
+ it('복제본이 원본과 객체를 공유하지 않는다 — 수정하면 따로 움직여야 한다', () => {
121
+ const next = duplicatePart([{ ...DECK, path: [{ x: 0, y: 0 }] }], 0)
122
+ next[1].path![0].x = 99
123
+
124
+ expect(next[0].path![0].x).toBe(0)
125
+ })
126
+
127
+ it('순서를 바꾼다', () => {
128
+ expect(movePart(PARTS, 2, 0).map(part => part.name)).toEqual(['wheel', 'deck', 'mast'])
129
+ })
130
+
131
+ it('범위를 벗어나면 그대로 둔다', () => {
132
+ expect(movePart(PARTS, 0, -1)).toBe(PARTS)
133
+ expect(movePart(PARTS, 2, 3)).toBe(PARTS)
134
+ })
135
+
136
+ it('원본을 수정하지 않는다', () => {
137
+ const before = JSON.stringify(PARTS)
138
+ removePart(PARTS, 0)
139
+ duplicatePart(PARTS, 0)
140
+ updatePart(PARTS, 0, { ...DECK, left: 0 })
141
+
142
+ expect(JSON.stringify(PARTS)).toBe(before)
143
+ })
144
+
145
+ it('부품 수 한도는 디테일 등급이 결정한다', () => {
146
+ expect(partLimit({ ...BOARD, detailLevel: 'S' })).toBeLessThan(partLimit({ ...BOARD, detailLevel: 'L' }))
147
+ })
148
+ })
149
+
150
+ /*
151
+ 정밀 배치 도구.
152
+
153
+ 씬 모델은 원점이 **모서리**다(left·top 이 0 에서 시작하고 zPos 는 밑면이다).
154
+ FigureSource 는 원점이 중심이다. 이전 구현은 FigureSource 기준이라 '가운데로'가 곧
155
+ '0 으로'였는데, 씬 용어에서는 그렇지 않다 — 이 절이 그 차이를 확인한다.
156
+ */
157
+ describe('정밀 배치 도구', () => {
158
+ it('가운데로 옮기면 변환했을 때 원점에 온다', () => {
159
+ const moved = centerPart(BOARD, [{ ...DECK, left: 0 }], 0, 'x')
160
+
161
+ expect(moved[0].left).toBe(1100)
162
+ expect(toFigureSource(BOARD, moved).parts[0].transform.position.x).toBe(0)
163
+ })
164
+
165
+ it('축마다 다른 속성을 바꾼다 — z 는 top 이고 y 는 zPos 다', () => {
166
+ expect(centerPart(BOARD, [DECK], 0, 'z')[0].top).toBe(700)
167
+ expect(centerPart(BOARD, [DECK], 0, 'y')[0].zPos).toBe(300)
168
+ })
169
+
170
+ it('바닥에 붙이면 zPos 가 0 이다 — 밑면이 곧 보드 바닥이다', () => {
171
+ expect(alignPartToStart([DECK], 0, 'y')[0].zPos).toBe(0)
172
+ })
173
+
174
+ it('끝에 붙이면 부품의 끝이 보드의 끝과 만난다', () => {
175
+ const moved = alignPartToEnd(BOARD, [DECK], 0, 'x')
176
+ expect(moved[0].left + moved[0].width).toBe(BOARD.width)
177
+ })
178
+
179
+ it('대칭 복제가 보드 안에서 반대쪽으로 간다', () => {
180
+ const parts = mirrorPart(BOARD, [{ ...DECK, left: 200 }], 0, 'x')
181
+
182
+ expect(parts).toHaveLength(2)
183
+ // 왼쪽 여백 200 → 오른쪽 여백도 200 이어야 한다
184
+ expect(parts[1].left).toBe(BOARD.width - 200 - DECK.width)
185
+ })
186
+
187
+ it('대칭 복제본이 어느 쪽인지 이름에 드러난다', () => {
188
+ expect(mirrorPart(BOARD, [{ ...DECK, left: 200 }], 0, 'x')[1].name).toBe('deck-x')
189
+ })
190
+
191
+ it('대칭 복제가 원본을 건드리지 않는다', () => {
192
+ const parts = mirrorPart(BOARD, [{ ...DECK, left: 200 }], 0, 'x')
193
+ expect(parts[0].left).toBe(200)
194
+ })
195
+
196
+ /*
197
+ 이미 가운데면 사본이 같은 자리에 생긴다. 화면에는 아무 변화가 없어 보이는데 부품 수만
198
+ 늘고 겹친 면에서 z-fighting 이 일어난다.
199
+ */
200
+ it('이미 가운데면 대칭 복제를 막는다', () => {
201
+ const centered = centerPart(BOARD, [DECK], 0, 'x')
202
+
203
+ expect(canMirror(BOARD, centered, 0, 'x')).toBe(false)
204
+ expect(mirrorPart(BOARD, centered, 0, 'x')).toHaveLength(1)
205
+ })
206
+
207
+ it('가운데가 아니면 대칭 복제할 수 있다', () => {
208
+ expect(canMirror(BOARD, [{ ...DECK, left: 200 }], 0, 'x')).toBe(true)
209
+ })
210
+
211
+ it('격자에 맞추면 위치와 크기가 격자 배수가 된다', () => {
212
+ const step = gridStep(BOARD)
213
+ const snapped = snapPart(BOARD, [{ ...DECK, left: 1213, width: 787 }], 0)
214
+
215
+ expect(snapped[0].left % step).toBe(0)
216
+ expect(snapped[0].width % step).toBe(0)
217
+ })
218
+
219
+ it('격자에 맞춰도 크기가 0 이 되지 않는다 — 사라진 것처럼 보인다', () => {
220
+ const snapped = snapPart(BOARD, [{ ...DECK, width: 1, height: 1, depth: 1 }], 0)
221
+
222
+ expect(snapped[0].width).toBeGreaterThan(0)
223
+ expect(snapped[0].height).toBeGreaterThan(0)
224
+ expect(snapped[0].depth).toBeGreaterThan(0)
225
+ })
226
+
227
+ it('없는 인덱스를 주면 목록을 그대로 반환한다', () => {
228
+ const parts = [DECK]
229
+ expect(centerPart(BOARD, parts, 7, 'x')).toBe(parts)
230
+ expect(snapPart(BOARD, parts, 7)).toBe(parts)
231
+ expect(mirrorPart(BOARD, parts, 7, 'x')).toBe(parts)
232
+ expect(canMirror(BOARD, parts, 7, 'x')).toBe(false)
233
+ })
234
+ })
235
+
236
+ describe('격자 간격', () => {
237
+ it('읽기 좋은 값이 나온다 — 27·33 같은 간격은 좌표를 읽을 수 없게 만든다', () => {
238
+ expect(niceStep(3000)).toBe(500)
239
+ expect(niceStep(1000)).toBe(200)
240
+ expect(niceStep(137)).toBe(20)
241
+ expect(niceStep(4)).toBe(1)
242
+ })
243
+
244
+ it('아무리 작아도 0 이 되지 않는다 — 0 으로 나누면 NaN 이 된다', () => {
245
+ expect(niceStep(0)).toBeGreaterThan(0)
246
+ expect(niceStep(-100)).toBeGreaterThan(0)
247
+ })
248
+
249
+ it('보드의 긴 쪽을 기준으로 잡는다 — 3D 바닥 격자와 같아야 한다', () => {
250
+ expect(gridStep(BOARD)).toBe(niceStep(3000))
251
+ })
252
+ })