@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,246 @@
1
+ import { EXTRUDE_PRIMITIVES, SEGMENTED_PRIMITIVES, SEGMENT_PRESETS, PART_LIMIT } from '@hatiolab/figure-model'
2
+ import type { DetailLevel, PrimitiveKind } from '@hatiolab/figure-model'
3
+
4
+ import { AXIS_KEYS, boardExtent } from './figure-source.js'
5
+ import type { BoardModel, PartModel, PlaneAxis } from './figure-source.js'
6
+
7
+ /**
8
+ * 부품 편집 규칙. **씬 모델의 용어로** 다룬다.
9
+ *
10
+ * 편집 중에 기준이 되는 데이터는 씬 모델이다 — 보드 위에 부품 컴포넌트가 놓인 상태.
11
+ * 그래서 편집도 씬의 용어(`left`·`top`·`zPos`·`width`·`height`·`depth`)로 한다.
12
+ * FigureSource 의 좌표계로 바꾸는 변환은 `figure-source.ts` 한 곳에서만 한다.
13
+ *
14
+ * 사용자가 보는 축(X·Y·Z)이 씬의 어느 속성인지도 `figure-source.ts` 의 `AXIS_KEYS` 가 기준이다.
15
+ * 여기서 다시 정의하지 않는다.
16
+ *
17
+ * 모든 함수가 **새 배열·새 객체를 반환한다** — 원본을 수정하지 않는다. Lit 이 변경을
18
+ * 감지하려면 참조가 달라져야 하고, 실행 취소를 붙일 때도 이전 상태가 남아 있어야 한다.
19
+ */
20
+
21
+ /** 중복되지 않는 이름을 만든다. 부품 이름은 저장되는 식별자라 중복되면 검증에서 걸린다. */
22
+ function freeName(taken: Set<string>, base: string): string {
23
+ if (!taken.has(base)) return base
24
+ for (let n = 2; ; n++) {
25
+ const candidate = `${base}-${n}`
26
+ if (!taken.has(candidate)) return candidate
27
+ }
28
+ }
29
+
30
+ /** 이 보드에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
31
+ export function partLimit(board: BoardModel): number {
32
+ return PART_LIMIT[(board.detailLevel ?? 'M') as DetailLevel]
33
+ }
34
+
35
+ /**
36
+ * 새 부품 하나를 만든다.
37
+ *
38
+ * 보드의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
39
+ * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
40
+ */
41
+ export function newPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel {
42
+ const taken = new Set(parts.map(part => part.name))
43
+
44
+ const width = Math.max(1, Math.round(board.width / 3))
45
+ const height = Math.max(1, Math.round(board.height / 3))
46
+ const depth = Math.max(1, Math.round(board.depth / 3))
47
+
48
+ const part: PartModel = {
49
+ type: 'figure-part',
50
+ name: freeName(taken, primitive),
51
+ primitive,
52
+ left: Math.round((board.width - width) / 2),
53
+ top: Math.round((board.height - height) / 2),
54
+ width,
55
+ height,
56
+ // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 보드 바닥이다.
57
+ zPos: 0,
58
+ depth,
59
+ // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
60
+ token: parts[0]?.token ?? 'palette.primary'
61
+ }
62
+
63
+ if (SEGMENTED_PRIMITIVES.includes(primitive)) {
64
+ part.segments = SEGMENT_PRESETS[1]
65
+ }
66
+ if (EXTRUDE_PRIMITIVES.includes(primitive)) {
67
+ part.round = 0
68
+ }
69
+ if (primitive === 'polygon') {
70
+ // polygon 은 점이 3개 이상이어야 한다. 없으면 검증에서 막히므로 삼각형으로 시작한다.
71
+ // 단면의 점은 부품 중심을 기준으로 적는다.
72
+ part.path = [
73
+ { x: -Math.round(width / 2), y: -Math.round(height / 2) },
74
+ { x: Math.round(width / 2), y: -Math.round(height / 2) },
75
+ { x: 0, y: Math.round(height / 2) }
76
+ ]
77
+ }
78
+
79
+ return part
80
+ }
81
+
82
+ export function addPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel[] {
83
+ return [...parts, newPart(board, parts, primitive)]
84
+ }
85
+
86
+ export function removePart(parts: PartModel[], index: number): PartModel[] {
87
+ return parts.filter((_, at) => at !== index)
88
+ }
89
+
90
+ /** 부품을 복제한다. 이름만 새로 붙인다 — 중복되면 검증에서 걸린다. */
91
+ export function duplicatePart(parts: PartModel[], index: number): PartModel[] {
92
+ const origin = parts[index]
93
+ if (!origin) return parts
94
+
95
+ const copy: PartModel = JSON.parse(JSON.stringify(origin))
96
+ copy.name = freeName(new Set(parts.map(part => part.name)), `${origin.name}-copy`)
97
+
98
+ const next = [...parts]
99
+ next.splice(index + 1, 0, copy)
100
+ return next
101
+ }
102
+
103
+ /** 목록에서 순서를 바꾼다. 범위를 벗어나면 그대로 둔다. */
104
+ export function movePart(parts: PartModel[], index: number, to: number): PartModel[] {
105
+ if (to < 0 || to >= parts.length || index === to) return parts
106
+
107
+ const next = [...parts]
108
+ const [moved] = next.splice(index, 1)
109
+ next.splice(to, 0, moved!)
110
+ return next
111
+ }
112
+
113
+ export function updatePart(parts: PartModel[], index: number, part: PartModel): PartModel[] {
114
+ return parts.map((old, at) => (at === index ? part : old))
115
+ }
116
+
117
+ /**
118
+ * 정밀 배치 도구 — 좌표를 손으로 계산하지 않게 한다.
119
+ *
120
+ * 보드 편집기는 자산을 **배치**하는 도구라 대략적인 위치로도 충분하다. 여기는 자산을
121
+ * **만드는** 도구라 다르다. 모터를 몸통 정중앙에 놓거나, 다리를 네 귀퉁이에 대칭으로
122
+ * 놓거나, 부품을 바닥에 정확히 붙이는 작업은 손으로 계산하면 늘 조금씩 어긋난다.
123
+ */
124
+
125
+ function put(parts: PartModel[], index: number, fields: Partial<PartModel>): PartModel[] {
126
+ const part = parts[index]
127
+ if (!part) return parts
128
+ return updatePart(parts, index, { ...part, ...fields })
129
+ }
130
+
131
+ /** 위치의 한 축만 바꾼다. 어느 속성인지는 `AXIS_KEYS` 가 결정한다. */
132
+ function putAt(parts: PartModel[], index: number, axis: PlaneAxis, value: number): PartModel[] {
133
+ return put(parts, index, { [AXIS_KEYS[axis].at]: value } as Partial<PartModel>)
134
+ }
135
+
136
+ /** 부품을 보드의 해당 축 가운데로 옮긴다. */
137
+ export function centerPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
138
+ const part = parts[index]
139
+ if (!part) return parts
140
+
141
+ return putAt(parts, index, axis, Math.round((boardExtent(board, axis) - part[AXIS_KEYS[axis].size]) / 2))
142
+ }
143
+
144
+ /** 부품을 보드 바닥(또는 해당 축의 시작)에 붙인다. */
145
+ export function alignPartToStart(parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
146
+ return putAt(parts, index, axis, 0)
147
+ }
148
+
149
+ /** 부품을 보드의 끝에 붙인다. */
150
+ export function alignPartToEnd(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
151
+ const part = parts[index]
152
+ if (!part) return parts
153
+
154
+ return putAt(parts, index, axis, Math.round(boardExtent(board, axis) - part[AXIS_KEYS[axis].size]))
155
+ }
156
+
157
+ /**
158
+ * 부품을 축 기준으로 대칭 복제한다.
159
+ *
160
+ * 다리 넷, 팔 둘 같은 형상을 이것 없이 만들면 고생한다. 이름은 **어느 쪽인지 알 수
161
+ * 있게** 붙인다 — `leg` 를 x 축으로 대칭하면 `leg-x`.
162
+ */
163
+ export function mirrorPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
164
+ const part = parts[index]
165
+ if (!part || !canMirror(board, parts, index, axis)) return parts
166
+
167
+ const copy = JSON.parse(JSON.stringify(part)) as PartModel
168
+ copy.name = freeName(new Set(parts.map(p => p.name)), `${part.name}-${axis}`)
169
+
170
+ const next = [...parts]
171
+ next.splice(index + 1, 0, copy)
172
+ return putAt(next, index + 1, axis, mirroredAt(board, part, axis))
173
+ }
174
+
175
+ /** 대칭했을 때의 위치. 보드의 해당 축 안에서 뒤집는다. */
176
+ function mirroredAt(board: BoardModel, part: PartModel, axis: PlaneAxis): number {
177
+ const { at, size } = AXIS_KEYS[axis]
178
+ return Math.round(boardExtent(board, axis) - part[at] - part[size])
179
+ }
180
+
181
+ /**
182
+ * 대칭이 의미가 있는지.
183
+ *
184
+ * 이미 그 축의 가운데에 있는 부품을 대칭하면 **같은 자리에 사본이 생긴다.** 화면에는
185
+ * 아무 변화가 없어 보이는데 부품 수만 늘고, 겹친 면에서 z-fighting 이 일어난다.
186
+ * 만들어 놓고 알리는 대신 미리 막는다.
187
+ */
188
+ export function canMirror(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): boolean {
189
+ const part = parts[index]
190
+ if (!part) return false
191
+
192
+ return mirroredAt(board, part, axis) !== Math.round(part[AXIS_KEYS[axis].at])
193
+ }
194
+
195
+ /**
196
+ * 부품의 위치와 크기를 격자에 맞춘다.
197
+ *
198
+ * 격자 간격은 보드 크기에서 계산한다 — 고정 값이면 작은 형상에서는 너무 크고 큰
199
+ * 형상에서는 너무 잘다. 3D 바닥 격자와 같은 계산이라 화면에서 보이는 것과 일치한다.
200
+ */
201
+ export function snapPart(board: BoardModel, parts: PartModel[], index: number): PartModel[] {
202
+ const part = parts[index]
203
+ if (!part) return parts
204
+
205
+ const step = gridStep(board)
206
+ const round = (value: number) => Math.round(value / step) * step
207
+
208
+ return put(parts, index, {
209
+ left: round(part.left),
210
+ top: round(part.top),
211
+ zPos: round(part.zPos),
212
+ // 크기는 0 이 되면 안 된다 — 부품이 사라진 것처럼 보인다
213
+ width: Math.max(step, round(part.width)),
214
+ height: Math.max(step, round(part.height)),
215
+ depth: Math.max(step, round(part.depth))
216
+ })
217
+ }
218
+
219
+ /**
220
+ * 격자 간격. 3D 바닥에 그려지는 격자와 같은 값이다.
221
+ *
222
+ * **세 축을 다 본다.** 평면만 보면 키가 큰 모델에서 격자가 지나치게 잘아진다. 시그널타워
223
+ * (바닥 160 x 160, 높이 1100)는 평면 기준으로 간격이 20 이 나와서, 옆에서 보면 높이가
224
+ * 격자 55 칸이 된다. 격자는 크기를 가늠하는 자이므로 모델 전체에 대해 읽혀야 한다.
225
+ */
226
+ export function gridStep(board: BoardModel): number {
227
+ return niceStep(Math.max(board.width, board.height, board.depth))
228
+ }
229
+
230
+ /**
231
+ * 주어진 길이에 어울리는 **읽기 좋은** 격자 간격. 다섯 칸 정도가 되게 잡는다.
232
+ *
233
+ * 그냥 나누면 27·33 같은 값이 나온다. 그 격자에 맞추면 좌표가 54·81 이 되어 읽기
234
+ * 어렵다. 1·2·5 배수로 올림한다 — 자와 차트 눈금이 쓰는 방식이다.
235
+ *
236
+ * 격자가 성겨야 형상이 눈에 먼저 들어온다. 열 칸으로 잡았더니 격자가 촘촘해 배경이
237
+ * 먼저 보였다.
238
+ */
239
+ export function niceStep(span: number): number {
240
+ const raw = Math.max(span, 1) / 5
241
+ const magnitude = Math.pow(10, Math.floor(Math.log10(raw)))
242
+ const n = raw / magnitude
243
+
244
+ const multiple = n < 1.5 ? 1 : n < 3.5 ? 2 : n < 7.5 ? 5 : 10
245
+ return Math.max(1, multiple * magnitude)
246
+ }
@@ -0,0 +1,142 @@
1
+ import type { FigurePart, FigureSource } from '@hatiolab/figure-model'
2
+
3
+ /**
4
+ * AI 후보를 지금 것과 비교하고, 고른 것만 받아들인다.
5
+ *
6
+ * 비교는 **저장 형식**으로 한다. 편집 중의 데이터는 씬 모델이지만, 후보는 서버에서
7
+ * FigureSource 로 오고 기준 치수·디테일 등급처럼 부품 밖의 값도 함께 봐야 한다.
8
+ *
9
+ * 부품 편집 규칙은 여기 없다 — `part-edits.ts` 가 씬 모델 용어로 갖고 있다.
10
+ */
11
+
12
+ /**
13
+ * 후보가 무엇을 바꾸자는 것인가.
14
+ *
15
+ * 후보를 통째로만 받게 두면 「몸통은 좋은데 모터 자리가 틀렸다」에 답할 수 없다 —
16
+ * 그게 가장 흔한 반응이다. 그래서 후보를 **변경 목록**으로 본다.
17
+ *
18
+ * 부품은 이름으로 짝짓는다. `FigurePart.name` 이 저장되는 식별자이고 바인딩이
19
+ * 그 이름을 가리키므로, 이름이 같으면 같은 부품이다.
20
+ *
21
+ * 기준 치수와 디테일 등급도 목록에 넣는다. 부품만 골라 받으면 새 부품이 옛 기준
22
+ * 상자 밖으로 나가 **검증에 걸릴 수 있다** — 무엇을 함께 받아야 하는지 보여야 한다.
23
+ */
24
+ export type ChangeKind = 'added' | 'changed' | 'removed' | 'base' | 'detail' | 'anchor' | 'styleKit'
25
+
26
+ /**
27
+ * 부품 밖에서 바뀔 수 있는 것 전부.
28
+ *
29
+ * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도
30
+ * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.
31
+ */
32
+ const FIGURE_FIELDS = ['base', 'detailLevel', 'anchor', 'styleKit'] as const
33
+ const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
34
+ base: 'base',
35
+ detailLevel: 'detail',
36
+ anchor: 'anchor',
37
+ styleKit: 'styleKit'
38
+ }
39
+
40
+ export interface ProposalChange {
41
+ kind: ChangeKind
42
+ /** 부품 변경이면 부품 이름. 그 밖이면 무엇이 바뀌는지. */
43
+ key: string
44
+ /** 부품 변경일 때 후보 쪽 부품. */
45
+ part?: FigurePart
46
+ }
47
+
48
+ /** 부품 둘이 같은가. 순서까지 같은 JSON 으로 견준다 — 필드 순서는 만드는 쪽이 정한다. */
49
+ function samePart(a: FigurePart, b: FigurePart): boolean {
50
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))
51
+ }
52
+
53
+ /** 키 순서를 맞춰 견줄 수 있게 한다. */
54
+ function sortKeys(value: unknown): unknown {
55
+ if (Array.isArray(value)) return value.map(sortKeys)
56
+ if (value && typeof value === 'object') {
57
+ const out: Record<string, unknown> = {}
58
+ for (const key of Object.keys(value as object).sort()) {
59
+ out[key] = sortKeys((value as Record<string, unknown>)[key])
60
+ }
61
+ return out
62
+ }
63
+ return value
64
+ }
65
+
66
+ export function diffProposal(current: FigureSource | undefined, proposal: FigureSource): ProposalChange[] {
67
+ const changes: ProposalChange[] = []
68
+ const mine = new Map((current?.parts ?? []).map(part => [part.name, part]))
69
+ const theirs = new Map(proposal.parts.map(part => [part.name, part]))
70
+
71
+ for (const field of FIGURE_FIELDS) {
72
+ if (!current) break
73
+ const before = JSON.stringify(sortKeys(current[field]))
74
+ const after = JSON.stringify(sortKeys(proposal[field]))
75
+ if (before !== after) {
76
+ changes.push({ kind: FIELD_KIND[field], key: field })
77
+ }
78
+ }
79
+
80
+ for (const part of proposal.parts) {
81
+ const old = mine.get(part.name)
82
+ if (!old) changes.push({ kind: 'added', key: part.name, part })
83
+ else if (!samePart(old, part)) changes.push({ kind: 'changed', key: part.name, part })
84
+ }
85
+ for (const part of current?.parts ?? []) {
86
+ if (!theirs.has(part.name)) changes.push({ kind: 'removed', key: part.name })
87
+ }
88
+
89
+ return changes
90
+ }
91
+
92
+ /**
93
+ * 고른 변경만 받는다.
94
+ *
95
+ * **타입 이름은 지금 것을 지킨다.** 만든 뒤에는 고칠 수 없는 저장 식별자이고,
96
+ * 배치된 인스턴스가 그 이름으로 청사진을 찾는다. 후보가 다른 이름을 지어 왔어도
97
+ * 그것 때문에 이미 놓인 것이 빈 자리가 되면 안 된다.
98
+ *
99
+ * `picked` 를 주지 않으면 전부 받는다 — 그것이 보통의 길이다.
100
+ *
101
+ * 더해지는 부품은 **뒤에 붙인다.** 후보가 놓은 자리에 끼워 넣으면, 일부만 골랐을
102
+ * 때 순서가 어디서 온 것인지 알 수 없게 된다.
103
+ */
104
+ export function applyProposal(
105
+ current: FigureSource | undefined,
106
+ proposal: FigureSource,
107
+ picked?: Set<string>
108
+ ): FigureSource {
109
+ const takes = (change: ProposalChange) => !picked || picked.has(change.key)
110
+ const changes = diffProposal(current, proposal)
111
+
112
+ if (!current) {
113
+ return { ...proposal }
114
+ }
115
+
116
+ const removed = new Set(changes.filter(c => c.kind === 'removed' && takes(c)).map(c => c.key))
117
+ const replaced = new Map(
118
+ changes.filter(c => c.kind === 'changed' && takes(c)).map(c => [c.key, c.part as FigurePart])
119
+ )
120
+ const added = changes.filter(c => c.kind === 'added' && takes(c)).map(c => c.part as FigurePart)
121
+
122
+ const parts = [
123
+ ...current.parts.filter(part => !removed.has(part.name)).map(part => replaced.get(part.name) ?? part),
124
+ ...added
125
+ ]
126
+
127
+ // 고른 필드만 후보 값으로 바꾼다. 안 고른 것은 지금 값이 남는다.
128
+ const fields: Record<string, unknown> = {}
129
+ for (const field of FIGURE_FIELDS) {
130
+ const change = changes.find(c => c.key === field)
131
+ if (change && takes(change)) {
132
+ fields[field] = proposal[field]
133
+ }
134
+ }
135
+
136
+ return { ...current, ...fields, parts } as FigureSource
137
+ }
138
+
139
+ /** 전부 받는다. */
140
+ export function takeProposal(current: FigureSource | undefined, proposal: FigureSource): FigureSource {
141
+ return applyProposal(current, proposal)
142
+ }
@@ -0,0 +1,102 @@
1
+ import type { FigureCost } from '@hatiolab/figure-model'
2
+
3
+ /**
4
+ * 한 도면이 감당하는 양 — 「몇 개까지 편한가」의 분모.
5
+ *
6
+ * ## 이 값은 figure-model 것이어야 한다
7
+ *
8
+ * figure-model 의 `LIMITS` 는 전부 **Figure 하나짜리** 한도다(묶음 3 · 애니메이션 2 …).
9
+ * 「몇 개를 놓을 수 있나」는 한 도면이 감당하는 양을 알아야 나오는데, 그 값은 아직
10
+ * 어디에도 선언돼 있지 않다. 여기 두는 것은 임시다 — figure-model 이 선언하면 이
11
+ * 파일을 지운다. 두 벌이 되면 갈린다.
12
+ *
13
+ * ## 지어낸 수가 아니다
14
+ *
15
+ * `docs/design/component-authoring-tool.md` §4.15 「목표 수치」에 적힌 값이다.
16
+ *
17
+ * 한 도면 자산 500 개
18
+ * draw call ≤ 2500
19
+ * 삼각형 ≤ 50 만
20
+ *
21
+ * 그 문서가 스스로 이렇게 적어 두었다 — **「측정 없이 정한 목표다. 저작도구가 서면
22
+ * 실제 보드로 재고 조정한다」**(미결 D-20). 그래서 화면에도 근거를 함께 띄운다.
23
+ * 숫자만 크게 내걸면 재 본 값처럼 읽힌다.
24
+ */
25
+ /**
26
+ * 그림자 패스가 draw call 을 두 배로 만든다.
27
+ *
28
+ * 실측이다(2026-09-05, Apple M5). 자산 500~5,500 어디서나 실측이 figure-model 예측의
29
+ * **정확히 2.00 배**로 나왔고, 그림자 맵을 끄면 예측과 같아졌다. 그림자 맵은 장면을
30
+ * 한 번 더 그리기 때문이다. figure-model 의 `at.drawCalls` 는 색 패스만 센다.
31
+ *
32
+ * ## 등급 S 도 그림자를 진다
33
+ *
34
+ * 설계 문서 §4.15 는 「그림자는 등급 M 이상만」이라고 정했다. 그래서 처음에는 등급이
35
+ * S 면 이 곱을 빼려 했다. **그런데 등급 S 로 세워 재 보니 draw call 이 똑같이 두 배로
36
+ * 나왔다** — 그 규칙은 문서에만 있고 제품에는 없다.
37
+ *
38
+ * 그래서 등급을 보지 않고 언제나 곱한다. 없는 규칙을 믿고 셈하면 화면이 「S 로 하면
39
+ * 두 배 놓을 수 있다」고 거짓말을 한다. things-scene 이 그 규칙을 구현하면 그때
40
+ * 등급을 보면 된다 — 그쪽에 알릴 것.
41
+ */
42
+ export const SHADOW_PASS_FACTOR = 2
43
+
44
+ export const SCENE_BUDGET = {
45
+ /** 한 도면의 draw call. 로우폴리에서 먼저 차는 쪽이 대개 이것이다. */
46
+ drawCalls: 2500,
47
+ /** 한 도면의 삼각형. */
48
+ triangles: 500_000
49
+ } as const
50
+
51
+ /** 무엇이 먼저 차나. */
52
+ export type BindingBudget = 'drawCalls' | 'triangles'
53
+
54
+ export interface FigureCapacity {
55
+ /** 이 형상을 몇 개까지 놓을 수 있나. */
56
+ instances: number
57
+ /** 먼저 차는 예산. **여기부터 고치면 된다.** */
58
+ bound: BindingBudget
59
+ /** 인스턴스 하나가 무는 값. */
60
+ per: number
61
+ /** 그 예산의 한 도면 총량. */
62
+ budget: number
63
+ }
64
+
65
+ /**
66
+ * 몇 개까지 놓을 수 있나.
67
+ *
68
+ * 두 예산 중 **먼저 차는 쪽**이 답이다. 평균이나 곱을 내지 않는다 — 하나만 꽉 차도
69
+ * 그것이 병목이고, 저작자가 고쳐야 할 곳은 그 하나다.
70
+ *
71
+ * 셈은 여기서 하지만 재료는 전부 figure-model 이 낸 것이다. `cost.groups` 가 인스턴스
72
+ * 하나당 draw call 이고 `cost.triangles` 가 인스턴스 하나의 삼각형 수다.
73
+ */
74
+ export function capacityOf(cost: FigureCost): FigureCapacity | undefined {
75
+ return capacityFrom(cost.groups, cost.triangles)
76
+ }
77
+
78
+ /**
79
+ * 저장된 값만으로 셈한다.
80
+ *
81
+ * 목록은 정본을 받아 오지 않는다 — 카드 수십 장마다 컴파일하면 목록이 무거워진다.
82
+ * 대신 서버가 저장해 둔 `groups` · `triangles` 를 쓴다. **목록과 저작면이 같은 함수를
83
+ * 쓰는 것**이 요점이다. 카드가 제 나름대로 셈하면 같은 형상이 두 화면에서 다른 개수로
84
+ * 나온다.
85
+ */
86
+ export function capacityFrom(groups: number, triangles: number): FigureCapacity | undefined {
87
+ // 부품이 없으면 「무한히 놓을 수 있다」가 아니라 **답할 것이 없다**.
88
+ if (groups <= 0 && triangles <= 0) {
89
+ return undefined
90
+ }
91
+
92
+ // 화면이 실제로 내는 draw call. 그림자 패스가 장면을 한 번 더 그린다 — 등급과 무관하게.
93
+ const calls = groups * SHADOW_PASS_FACTOR
94
+
95
+ const byDrawCalls = calls > 0 ? Math.floor(SCENE_BUDGET.drawCalls / calls) : Infinity
96
+ const byTriangles = triangles > 0 ? Math.floor(SCENE_BUDGET.triangles / triangles) : Infinity
97
+
98
+ return byDrawCalls <= byTriangles
99
+ ? { instances: byDrawCalls, bound: 'drawCalls', per: calls, budget: SCENE_BUDGET.drawCalls }
100
+ : { instances: byTriangles, bound: 'triangles', per: triangles, budget: SCENE_BUDGET.triangles }
101
+ }
102
+