@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,336 @@
1
+ import { existsSync, readFileSync } from 'fs'
2
+ import { dirname, join } from 'path'
3
+
4
+ import { toFigureSource, fromFigureSource } from './figure-source'
5
+ import type { BoardModel, PartModel } from './figure-source'
6
+ import type { FigureSource } from '@hatiolab/figure-model'
7
+
8
+ /**
9
+ * 씬 모델 ↔ `FigureSource`.
10
+ *
11
+ * 저작 중의 정본은 씬 모델이고, 형식은 저장하거나 판정할 때 접어서 만든다. 두 어휘가
12
+ * 다르므로 **옮기는 일이 한 곳에만** 있어야 하고, 그 한 곳이 맞는지를 여기서 못 박는다.
13
+ *
14
+ * 가장 중요한 것은 왕복이다. 접었다 펴면 같아야 하고 펴서 접어도 같아야 한다 —
15
+ * 어느 한쪽에서만 맞으면 AI 가 제안해 온 정본을 편집하는 순간 값이 샌다.
16
+ */
17
+
18
+ /** 판 3000 x 2000 x 800 — 세 축이 다 달라야 축을 바꿔 써도 시험이 잡는다. */
19
+ const BOARD: BoardModel = {
20
+ width: 3000,
21
+ height: 2000,
22
+ depth: 800,
23
+ figureType: 'AGV',
24
+ detailLevel: 'L'
25
+ }
26
+
27
+ const PART: PartModel = {
28
+ type: 'figure-part',
29
+ name: 'deck',
30
+ primitive: 'cube',
31
+ left: 1100,
32
+ top: 700,
33
+ width: 800,
34
+ height: 600,
35
+ zPos: 300,
36
+ depth: 200,
37
+ token: 'palette.primary'
38
+ }
39
+
40
+ describe('접기 — 씬 모델을 형식으로', () => {
41
+ it('판이 기준 상자가 된다 — 높이는 판의 depth 다', () => {
42
+ const { base } = toFigureSource(BOARD, [])
43
+ expect(base).toEqual({ x: 3000, y: 800, z: 2000 })
44
+ })
45
+
46
+ it('부품 자리가 판 중심 기준이 된다', () => {
47
+ const [part] = toFigureSource(BOARD, [PART]).parts
48
+
49
+ // 평면 중심 (1100+400, 700+300) = (1500, 1000), 판 중심 (1500, 1000) → 원점
50
+ expect(part.transform.position.x).toBe(0)
51
+ expect(part.transform.position.z).toBe(0)
52
+ })
53
+
54
+ it('zPos 는 밑면이다 — 중심으로 올려 적는다', () => {
55
+ const [part] = toFigureSource(BOARD, [PART]).parts
56
+
57
+ // 밑면 300 + 두께 200/2 = 중심 400. 판 높이 800 의 절반을 빼면 0
58
+ expect(part.transform.position.y).toBe(0)
59
+ })
60
+
61
+ it('가운데를 벗어난 부품이 그만큼 어긋나 적힌다', () => {
62
+ const moved = { ...PART, left: 1200, top: 900, zPos: 500 }
63
+ const [part] = toFigureSource(BOARD, [moved]).parts
64
+
65
+ expect(part.transform.position).toEqual({ x: 100, y: 200, z: 200 })
66
+ })
67
+
68
+ it('크기가 축 이름으로 바뀐다 — 두께가 y 다', () => {
69
+ const [part] = toFigureSource(BOARD, [PART]).parts
70
+ expect(part.transform.size).toEqual({ x: 800, y: 200, z: 600 })
71
+ })
72
+
73
+ /*
74
+ 씬은 라디안을 쓰고 이름이 뒤섞여 있다 — rotationY 가 three 의 z 로 간다.
75
+ 형식은 도를 쓰고 축 이름이 그대로다.
76
+ */
77
+ it('회전이 도로 바뀌고 축이 제자리를 찾는다', () => {
78
+ const turned = { ...PART, rotationX: Math.PI / 6, rotation: Math.PI / 2, rotationY: Math.PI }
79
+ const [part] = toFigureSource(BOARD, [turned]).parts
80
+
81
+ expect(part.transform.rotation).toEqual({ x: 30, y: -90, z: 180 })
82
+ })
83
+
84
+ it('회전이 없으면 필드를 두지 않는다', () => {
85
+ const [part] = toFigureSource(BOARD, [PART]).parts
86
+ expect(part.transform.rotation).toBeUndefined()
87
+ })
88
+
89
+ it('재질이 한 덩어리로 모인다', () => {
90
+ const [part] = toFigureSource(BOARD, [{ ...PART, preset: 'metal', flatShading: false }]).parts
91
+ expect(part.material).toEqual({ token: 'palette.primary', preset: 'metal', flatShading: false })
92
+ })
93
+
94
+ it('단면 값이 shape 으로 모인다', () => {
95
+ const [part] = toFigureSource(BOARD, [{ ...PART, primitive: 'rect', round: 40 }]).parts
96
+ expect(part.shape).toEqual({ round: 40 })
97
+ })
98
+
99
+ it('단면 값이 없으면 shape 을 두지 않는다', () => {
100
+ expect(toFigureSource(BOARD, [PART]).parts[0].shape).toBeUndefined()
101
+ })
102
+ })
103
+
104
+ describe('펴기 — 형식을 씬 모델로', () => {
105
+ const SOURCE: FigureSource = {
106
+ type: 'AGV',
107
+ base: { x: 3000, y: 800, z: 2000 },
108
+ detailLevel: 'L',
109
+ parts: [
110
+ {
111
+ name: 'deck',
112
+ primitive: 'cube',
113
+ transform: { position: { x: 100, y: 200, z: 200 }, size: { x: 800, y: 200, z: 600 } },
114
+ material: { token: 'palette.primary' }
115
+ }
116
+ ]
117
+ }
118
+
119
+ it('기준 상자가 판이 된다', () => {
120
+ const { board } = fromFigureSource(SOURCE)
121
+ expect(board).toMatchObject({ width: 3000, height: 2000, depth: 800 })
122
+ })
123
+
124
+ it('중심 기준 자리가 씬의 모서리·밑면으로 바뀐다', () => {
125
+ const [part] = fromFigureSource(SOURCE).parts
126
+ expect(part).toMatchObject({ left: 1200, top: 900, zPos: 500 })
127
+ })
128
+
129
+ it('부품이 씬 타입을 갖는다 — 판에 그대로 놓을 수 있어야 한다', () => {
130
+ expect(fromFigureSource(SOURCE).parts[0].type).toBe('figure-part')
131
+ })
132
+ })
133
+
134
+ /*
135
+ 왕복.
136
+
137
+ 이것이 이 모듈의 존재 이유다. 한쪽에서만 맞으면 AI 가 제안해 온 정본을 펴서
138
+ 편집하고 다시 접는 순간 값이 샌다 — 그것도 조용히.
139
+ */
140
+ /**
141
+ * 형식이 가진 것을 되도록 다 담은 정본. 안 쓰이는 필드도 넣는다.
142
+ *
143
+ * describe 밖에 둔다 — 아래 「픽스처가 형식을 다 담는다」가 이것을 형식의 필드 목록과
144
+ * 맞대어 본다.
145
+ */
146
+ /** 형식이 가진 것을 되도록 다 담은 정본. 안 쓰이는 필드도 넣는다. */
147
+ const FULL: FigureSource = {
148
+ type: 'AGV',
149
+ base: { x: 3000, y: 800, z: 2000 },
150
+ detailLevel: 'L',
151
+ styleKit: 'factory',
152
+ anchor: { x: 0, y: -400, z: 0 },
153
+ parts: [
154
+ {
155
+ name: 'deck',
156
+ primitive: 'cube',
157
+ transform: { position: { x: 100, y: 200, z: 200 }, size: { x: 800, y: 200, z: 600 } },
158
+ material: { token: 'palette.primary', preset: 'metal', flatShading: true }
159
+ },
160
+ {
161
+ name: 'wheel',
162
+ primitive: 'cylinder',
163
+ segments: 24,
164
+ transform: {
165
+ position: { x: -700, y: -260, z: 420 },
166
+ size: { x: 180, y: 70, z: 180 },
167
+ rotation: { z: 90 }
168
+ },
169
+ material: { token: 'palette.dark', preset: 'rubber' }
170
+ },
171
+ {
172
+ name: 'panel',
173
+ primitive: 'rect',
174
+ // 속을 판 단면. 한 겹 안이라 처음 판 시험이 못 봤던 자리다.
175
+ shape: { round: 40, hollow: { wall: 8, floor: 4 } },
176
+ transform: {
177
+ position: { x: 0, y: 260, z: -500 },
178
+ size: { x: 400, y: 30, z: 200 },
179
+ rotation: { x: 15, y: -30, z: 45 }
180
+ },
181
+ material: { token: 'palette.accent', transparent: true },
182
+ // 아직 아무 일도 안 하는 필드들. 잃어버리면 안 된다.
183
+ materialSlot: 'cap',
184
+ keepRound: false,
185
+ sizing: 'fixed',
186
+ anchor: { x: 'span', y: 'max', z: 'center' },
187
+ label: { name: 'rpm', source: 'speed', when: 'zoomed' }
188
+ },
189
+ {
190
+ name: 'foot',
191
+ primitive: 'polygon',
192
+ shape: {
193
+ path: [
194
+ { x: -60, y: -40 },
195
+ { x: 60, y: -40 },
196
+ { x: 0, y: 40 }
197
+ ]
198
+ },
199
+ transform: { position: { x: 900, y: -340, z: -600 }, size: { x: 120, y: 80, z: 80 } },
200
+ material: { token: 'palette.neutral' },
201
+ sizing: 'repeat',
202
+ repeat: { axis: 'x', pitch: 200 }
203
+ }
204
+ ],
205
+ /*
206
+ 움직임. 편집면이 아직 안 만지는 자리이며, **바로 그래서 여기 있다.**
207
+
208
+ 이 픽스처가 「형식이 가진 것을 되도록 다 담은 정본」인데 clip 이 빠져 있었고, 그
209
+ 사이 `toFigureSource` 가 clip 을 안 실었다. 시험은 초록이었다 — 픽스처에 없는 것은
210
+ 시험이 못 본다. 표본 롤러에 clip 을 적고 미리보기에서 아무것도 안 돌고 나서야 알았다.
211
+ */
212
+ animations: [
213
+ {
214
+ name: 'spin',
215
+ drive: 'loop',
216
+ channels: [
217
+ {
218
+ target: 'wheel',
219
+ path: 'rotation',
220
+ pivot: { x: 0, y: 0, z: 0 },
221
+ keys: [
222
+ { at: 0, value: { x: 0, y: 0, z: 0 } },
223
+ { at: 1, value: { x: 0, y: 360, z: 0 } }
224
+ ]
225
+ }
226
+ ]
227
+ }
228
+ ]
229
+ }
230
+
231
+ describe('왕복해도 같다', () => {
232
+
233
+ it('펴서 접으면 그대로다', () => {
234
+ const { board, parts } = fromFigureSource(FULL)
235
+ expect(toFigureSource(board, parts)).toEqual(FULL)
236
+ })
237
+
238
+ it('접어서 펴면 그대로다', () => {
239
+ const { board, parts } = fromFigureSource(FULL)
240
+ const again = fromFigureSource(toFigureSource(board, parts))
241
+
242
+ expect(again.board).toEqual(board)
243
+ expect(again.parts).toEqual(parts)
244
+ })
245
+
246
+ it('두 번 왕복해도 값이 밀리지 않는다 — 라디안·도를 오가도', () => {
247
+ let source = FULL
248
+ for (let i = 0; i < 5; i++) {
249
+ const { board, parts } = fromFigureSource(source)
250
+ source = toFigureSource(board, parts)
251
+ }
252
+ expect(source).toEqual(FULL)
253
+ })
254
+
255
+ it('움직임도 살아남는다 — 열었다 저장하는 것만으로 지워지던 자리다', () => {
256
+ const { board, parts } = fromFigureSource(FULL)
257
+
258
+ expect(toFigureSource(board, parts).animations).toEqual(FULL.animations)
259
+ })
260
+
261
+ it('안 쓰이는 필드도 살아남는다 — 조용히 사라지면 저작자가 알 길이 없다', () => {
262
+ const { board, parts } = fromFigureSource(FULL)
263
+ const panel = toFigureSource(board, parts).parts.find(part => part.name === 'panel')!
264
+
265
+ expect(panel.materialSlot).toBe('cap')
266
+ expect(panel.sizing).toBe('fixed')
267
+ expect(panel.label).toEqual({ name: 'rpm', source: 'speed', when: 'zoomed' })
268
+ })
269
+ })
270
+
271
+ /*
272
+ 픽스처가 형식을 따라가는지.
273
+
274
+ **이 파일에서 필드가 네 번 샜다** — `ports` · `animations` · `anchor` · `shape.hollow`.
275
+ 네 번 다 왕복 시험은 초록이었다. 위의 `FULL` 이 「형식이 가진 것을 되도록 다 담은
276
+ 정본」이라고 적혀 있었지만 실제로는 그때그때 손으로 채운 것이라, 형식에 필드가 늘면
277
+ 픽스처는 그대로 있고 시험은 늘어난 자리를 보지 못했다.
278
+
279
+ 네 번째 것은 **한 겹 안**이라 최상위만 세던 첫 판이 놓쳤다. 그래서 픽스처의 키를
280
+ 깊이 상관없이 다 모아 놓고 맞댄다.
281
+
282
+ 그래서 픽스처를 믿지 않고 **형식이 스스로 밝힌 필드 목록**을 읽는다. 형식이 붙은
283
+ 타입 선언(`dist/types.d.ts`)이 정본이고, 거기 적힌 이름이 픽스처에 없으면 여기서
284
+ 깨진다. 다음 번 필드는 화면이 아니라 이 시험이 먼저 잡는다.
285
+ */
286
+ describe('픽스처가 형식을 다 담는다', () => {
287
+ /**
288
+ * 형식이 붙여 보낸 타입 선언 파일.
289
+ *
290
+ * `require.resolve` 로는 못 잡는다 — jest 의 resolver 가 `.d.ts` 를 모듈로 보지
291
+ * 않는다. 올라가면서 찾고, 못 찾으면 조용히 건너뛰지 않고 여기서 깨진다.
292
+ */
293
+ function declarationPath(): string {
294
+ for (let at = __dirname; at !== dirname(at); at = dirname(at)) {
295
+ const candidate = join(at, 'node_modules/@hatiolab/figure-model/dist/types.d.ts')
296
+ if (existsSync(candidate)) return candidate
297
+ }
298
+ throw new Error('figure-model 의 타입 선언을 못 찾았다 — 이 시험은 그것이 정본이다')
299
+ }
300
+
301
+ /** 붙은 타입 선언에서 한 인터페이스의 필드 이름을 뽑는다. */
302
+ function fieldsOf(name: string): string[] {
303
+ const declared = readFileSync(declarationPath(), 'utf-8')
304
+ const start = declared.indexOf(`export interface ${name} {`)
305
+ expect(start).toBeGreaterThan(-1)
306
+
307
+ // 중첩이 없는 평평한 인터페이스다. 다음 닫는 중괄호가 끝이다.
308
+ const body = declared.slice(start, declared.indexOf('\n}', start))
309
+ return [...body.matchAll(/^\s{4}(\w+)\??:/gm)].map(m => m[1])
310
+ }
311
+
312
+ /** 픽스처가 쓴 키를 깊이 상관없이 모은다 — 흘린 필드가 한 겹 안에 있었다. */
313
+ function keysDeep(value: unknown, into = new Set<string>()): Set<string> {
314
+ if (Array.isArray(value)) value.forEach(one => keysDeep(one, into))
315
+ else if (value && typeof value === 'object') {
316
+ for (const [key, one] of Object.entries(value)) {
317
+ into.add(key)
318
+ keysDeep(one, into)
319
+ }
320
+ }
321
+ return into
322
+ }
323
+
324
+ const WRITTEN = keysDeep(FULL)
325
+
326
+ /*
327
+ `fold` 가 실제로 낱개로 푸는 것들이다. 통째로 나르는 구조(`transform` 처럼)는 필드가
328
+ 늘어도 안 새므로 여기 없다.
329
+ */
330
+ it.each(['FigureSource', 'FigurePart', 'PartShape', 'HollowSpec'])(
331
+ '%s 가 가진 모든 필드가 왕복 픽스처에 있다',
332
+ name => {
333
+ expect(fieldsOf(name).filter(field => !WRITTEN.has(field))).toEqual([])
334
+ }
335
+ )
336
+ })
@@ -0,0 +1,338 @@
1
+ import type { DetailLevel, FigurePart, FigureSource, Vec3 } from '@hatiolab/figure-model'
2
+
3
+ /**
4
+ * Scene model <-> `FigureSource`.
5
+ *
6
+ * ## Why this is the only place that converts
7
+ *
8
+ * While authoring, the source of truth is the **scene model** -- part
9
+ * components sitting on a board. `FigureSource` is produced from it when the
10
+ * asset is saved or scored.
11
+ *
12
+ * The two use different vocabularies. The scene inherited 2D canvas terms
13
+ * (`width`/`height` in plan, `depth` for height, `left`/`top`/`zPos` for
14
+ * position); the format uses the axes 3D tools use (`x` right, `y` up, `z`
15
+ * front, position at the part's **centre**).
16
+ *
17
+ * **Convert in one place.** Two places drift, and this format has already had
18
+ * one incident caused by axes disagreeing.
19
+ *
20
+ * ## It was called `fold`, and that was wrong
21
+ *
22
+ * `fold` is an established term -- it reduces a structure to a summary, and
23
+ * this repo already uses it correctly (`foldKpi`, `foldProbeResults`). Nothing
24
+ * is reduced here: the same information comes out in a different shape, and it
25
+ * goes back unchanged. A reader who learned `fold` from `foldKpi` reads this
26
+ * one as reducing something, and the user did exactly that.
27
+ *
28
+ * ## The mapping
29
+ *
30
+ * The board is the base box; its centre is the format's origin.
31
+ *
32
+ * format scene
33
+ * ──────────────── ──────────────────────────────
34
+ * base.x board width
35
+ * base.y board depth <- height
36
+ * base.z board height
37
+ *
38
+ * size.x width
39
+ * size.y depth
40
+ * size.z height
41
+ *
42
+ * position.x left + width/2 - board width/2
43
+ * position.y zPos + depth/2 - board depth/2 <- zPos is the underside
44
+ * position.z top + height/2 - board height/2
45
+ *
46
+ * rotation.x rotationX (scene radians, format degrees)
47
+ * rotation.y -rotation
48
+ * rotation.z rotationY
49
+ *
50
+ * The rotation names are crossed because of the scene: `rotationY` becomes
51
+ * three's z (`real-object.ts`, `get rotation()`). That stays inside this table.
52
+ *
53
+ * ## Fields nothing reads yet still travel
54
+ *
55
+ * `materialSlot`, `keepRound`, `sizing`, `anchor`, `repeat` and `label` exist in the format
56
+ * and the editor ignores them. They still have to survive a round trip. When a
57
+ * source proposed by AI is opened, edited and written back, a field that
58
+ * disappears **without an error** is one the author cannot know about.
59
+ *
60
+ * **Five have gone that way here**: `ports`, `animations`, `anchor`,
61
+ * `shape.hollow` and `keepRound`. The last one the test caught first, which
62
+ * is the guard below actually earning its keep. The third showed up as a conveyor motor that stayed on the
63
+ * floor no matter what the sample said; the fourth as a hollowed tray that
64
+ * came out a solid block.
65
+ *
66
+ * All four passed the round-trip test, because the fixture it uses did not
67
+ * carry the field. So the test now reads the **field list the format declares**
68
+ * rather than trusting the fixture.
69
+ *
70
+ * `shape` is unpacked into `round`, `path` and `hollow` rather than carried
71
+ * whole, because the editor holds those as separate state. Every field added
72
+ * under `shape` costs a line here, and forgetting that line was the fourth
73
+ * of the four.
74
+ */
75
+ /** 보드의 상태. 기준 상자이자 원본 데이터의 뼈대다. */
76
+ export interface BoardModel {
77
+ /** 기준 상자. 씬의 용어 그대로다. */
78
+ width: number
79
+ height: number
80
+ depth: number
81
+ /** 컴포넌트 타입 이름. **저장되는 식별자다.** */
82
+ figureType?: string
83
+ detailLevel?: DetailLevel
84
+ styleKit?: string
85
+ /**
86
+ * 형상 전체의 기준점.
87
+ *
88
+ * **부품의 `anchor` 와 다른 것이다.** 이쪽은 점(`Vec3`)이고 저쪽은 축별로 어느 면을
89
+ * 붙잡느냐다. 한 형식 안에 같은 낱말이 둘인 것은 좋지 않으나, 이름을 바꾸면 저장된
90
+ * 원본이 따라와야 하므로 여기서 정하지 않는다.
91
+ */
92
+ anchor?: Vec3
93
+ /**
94
+ * 움직임. **부품 바깥에 있다** — clip 하나가 부품 여럿을 함께 움직이기 때문이다.
95
+ *
96
+ * 편집면이 아직 이것을 만지지 않는다. 그래도 여기 실어 두는 것은, 안 실으면 열었다
97
+ * 저장하는 것만으로 **조용히 지워지기** 때문이다.
98
+ */
99
+ animations?: FigureSource['animations']
100
+ }
101
+
102
+ /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
103
+ export interface PartModel {
104
+ type: 'figure-part'
105
+ /** 부품 이름. **저장되는 식별자다.** 씬은 이 값을 검색에도 쓴다. */
106
+ name: string
107
+ primitive: FigurePart['primitive']
108
+
109
+ left: number
110
+ top: number
111
+ width: number
112
+ height: number
113
+ /** 부피의 **밑면** 높이. 씬의 'floor' 배치에서 그렇게 정해져 있다. */
114
+ zPos: number
115
+ depth: number
116
+
117
+ /** 라디안. 씬이 그렇게 쓴다. */
118
+ rotationX?: number
119
+ rotation?: number
120
+ rotationY?: number
121
+
122
+ segments?: number
123
+ round?: number
124
+ path?: { x: number; y: number }[]
125
+ /** 속을 판 단면. 편집면이 아직 안 만지지만 잃어버리면 통이 통짜 상자가 된다. */
126
+ hollow?: NonNullable<FigurePart['shape']>['hollow']
127
+
128
+ /**
129
+ * 편집 중에만 쓰는 상태 — **저장 형식으로 넘어가지 않는다.**
130
+ *
131
+ * 씬이 이미 갖고 있는 속성이다. 편집 모드에서는 `faint`(반투명 + 색을 버리고 회색으로
132
+ * 수렴)로 보이고, 뷰 모드에서는 아예 안 그려진다. 판단은 씬의 `modeling-faint` 한 곳에
133
+ * 있고 2D 캔버스·3D 재질·DOM 세 채널이 같이 쓴다.
134
+ *
135
+ * 숨김은 "지금 이 뒤를 만지려고" 하는 것이지 그 부품의 성질이 아니다. 그래서
136
+ * `toFigureSource` 가 싣지 않고, 다시 열면 전부 보이는 상태로 돌아온다.
137
+ */
138
+ hidden?: boolean
139
+
140
+ token?: string
141
+ preset?: FigurePart['material']['preset']
142
+ flatShading?: boolean
143
+ transparent?: boolean
144
+ /**
145
+ * 자체발광. 없으면 안 빛난다.
146
+ *
147
+ * 바깥의 `token` 이 **꺼진 모습**이고 이 안의 `token` 이 **켜진 모습**이다 — 꺼진
148
+ * 전구는 빨갛지 않고 어두운 플라스틱이다.
149
+ */
150
+ emissive?: FigurePart['material']['emissive']
151
+ surface?: FigurePart['material']['surface']
152
+
153
+ /* 아직 아무 일도 안 하지만 잃어버리지 않는다. */
154
+ materialSlot?: FigurePart['materialSlot']
155
+ /** 단면을 원으로 지킬까. 곡면만 뜻이 있고 기본은 지키는 것이다. */
156
+ keepRound?: FigurePart['keepRound']
157
+ sizing?: FigurePart['sizing']
158
+ /** 축마다 어느 면을 붙잡나. `sizing` 이 정한 것을 덮어쓴다. */
159
+ anchor?: FigurePart['anchor']
160
+ repeat?: FigurePart['repeat']
161
+ label?: FigurePart['label']
162
+ }
163
+
164
+ const RAD_TO_DEG = 180 / Math.PI
165
+ const DEG_TO_RAD = Math.PI / 180
166
+
167
+ /** 값이 있는 것만 남긴다. 없는 필드를 `undefined` 로 두면 접었다 편 것이 원본과 달라진다. */
168
+ function withoutEmpty<T extends object>(value: T): T {
169
+ const out = {} as Record<string, unknown>
170
+ for (const [key, item] of Object.entries(value)) {
171
+ if (item !== undefined) out[key] = item
172
+ }
173
+ return out as T
174
+ }
175
+
176
+ /** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */
177
+ function round4(value: number): number {
178
+ return Math.round(value * 1e4) / 1e4
179
+ }
180
+
181
+ /**
182
+ * 판의 절반. 형식의 원점이 판 중심이라 이 값이 늘 함께 다닌다.
183
+ *
184
+ * 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.
185
+ */
186
+ export function halfOf(board: BoardModel): Vec3 {
187
+ return { x: board.width / 2, y: board.depth / 2, z: board.height / 2 }
188
+ }
189
+
190
+ /**
191
+ * 저작자가 보는 축이 씬의 어느 용어인가.
192
+ *
193
+ * 씬은 2D 캔버스에서 온 용어를 쓰므로 축 이름이 그대로 없다. 이 표가 **유일한** 대응이다 —
194
+ * 편집 규칙이 저마다 자기 표를 들면 한 곳을 고칠 때 다른 곳이 남는다.
195
+ */
196
+ export const AXIS_KEYS = {
197
+ x: { at: 'left', size: 'width' },
198
+ y: { at: 'zPos', size: 'depth' },
199
+ z: { at: 'top', size: 'height' }
200
+ } as const
201
+
202
+ export type PlaneAxis = keyof typeof AXIS_KEYS
203
+
204
+ /** 판이 그 축으로 얼마나 넓은가. */
205
+ export function boardExtent(board: BoardModel, axis: PlaneAxis): number {
206
+ return axis === 'x' ? board.width : axis === 'y' ? board.depth : board.height
207
+ }
208
+
209
+ /** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */
210
+ export function partToFigure(part: PartModel, board: BoardModel): FigurePart {
211
+ return partTo(part, halfOf(board))
212
+ }
213
+
214
+ /** 부품 하나를 씬 용어로 되돌린다. */
215
+ export function partFromFigure(part: FigurePart, board: BoardModel): PartModel {
216
+ return partFrom(part, halfOf(board))
217
+ }
218
+
219
+ /** 씬 모델을 저장 형식으로 변환한다. */
220
+ export function toFigureSource(board: BoardModel, parts: PartModel[]): FigureSource {
221
+ const half = halfOf(board)
222
+
223
+ return withoutEmpty({
224
+ type: board.figureType ?? '',
225
+ base: { x: board.width, y: board.depth, z: board.height },
226
+ anchor: board.anchor,
227
+ detailLevel: board.detailLevel,
228
+ styleKit: board.styleKit,
229
+ parts: parts.map(part => partTo(part, half)),
230
+ animations: board.animations
231
+ }) as FigureSource
232
+ }
233
+
234
+ function partTo(part: PartModel, half: Vec3): FigurePart {
235
+ const rotation = withoutEmpty({
236
+ x: part.rotationX ? round4(part.rotationX * RAD_TO_DEG) : undefined,
237
+ y: part.rotation ? round4(-part.rotation * RAD_TO_DEG) : undefined,
238
+ z: part.rotationY ? round4(part.rotationY * RAD_TO_DEG) : undefined
239
+ })
240
+
241
+ const shape = withoutEmpty({ round: part.round, path: part.path, hollow: part.hollow })
242
+ const material = withoutEmpty({
243
+ token: part.token,
244
+ preset: part.preset,
245
+ flatShading: part.flatShading,
246
+ transparent: part.transparent,
247
+ emissive: part.emissive,
248
+ surface: part.surface
249
+ })
250
+
251
+ return withoutEmpty({
252
+ name: part.name,
253
+ primitive: part.primitive,
254
+ transform: withoutEmpty({
255
+ position: {
256
+ x: round4(part.left + part.width / 2 - half.x),
257
+ y: round4(part.zPos + part.depth / 2 - half.y),
258
+ z: round4(part.top + part.height / 2 - half.z)
259
+ },
260
+ size: { x: part.width, y: part.depth, z: part.height },
261
+ rotation: Object.keys(rotation).length > 0 ? rotation : undefined
262
+ }),
263
+ segments: part.segments,
264
+ shape: Object.keys(shape).length > 0 ? shape : undefined,
265
+ material,
266
+ materialSlot: part.materialSlot,
267
+ keepRound: part.keepRound,
268
+ sizing: part.sizing,
269
+ anchor: part.anchor,
270
+ repeat: part.repeat,
271
+ label: part.label
272
+ }) as FigurePart
273
+ }
274
+
275
+ /** 형식을 씬 모델로 편다. */
276
+ export function fromFigureSource(source: FigureSource): { board: BoardModel; parts: PartModel[] } {
277
+ const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
278
+
279
+ const board = withoutEmpty({
280
+ width: source.base.x,
281
+ height: source.base.z,
282
+ depth: source.base.y,
283
+ figureType: source.type,
284
+ detailLevel: source.detailLevel,
285
+ styleKit: source.styleKit,
286
+ anchor: source.anchor,
287
+ animations: source.animations
288
+ }) as BoardModel
289
+
290
+ return { board, parts: source.parts.map(part => partFrom(part, half)) }
291
+ }
292
+
293
+ function partFrom(part: FigurePart, half: Vec3): PartModel {
294
+ const { position, size, rotation } = part.transform
295
+ const material = part.material ?? {}
296
+
297
+ return withoutEmpty({
298
+ type: 'figure-part',
299
+ name: part.name,
300
+ primitive: part.primitive,
301
+
302
+ left: round4(half.x + position.x - size.x / 2),
303
+ top: round4(half.z + position.z - size.z / 2),
304
+ width: size.x,
305
+ height: size.z,
306
+ zPos: round4(half.y + position.y - size.y / 2),
307
+ depth: size.y,
308
+
309
+ /*
310
+ 라디안은 자르지 않는다.
311
+
312
+ 소수 넷째에서 잘랐더니 도로 되돌릴 때 57 배로 커져 45 도가 45.0001 이 됐다.
313
+ 자르는 것은 **사람이 읽는 쪽**(도)에서만 한다.
314
+ */
315
+ rotationX: rotation?.x ? rotation.x * DEG_TO_RAD : undefined,
316
+ rotation: rotation?.y ? -rotation.y * DEG_TO_RAD : undefined,
317
+ rotationY: rotation?.z ? rotation.z * DEG_TO_RAD : undefined,
318
+
319
+ segments: part.segments,
320
+ round: part.shape?.round,
321
+ path: part.shape?.path,
322
+ hollow: part.shape?.hollow,
323
+
324
+ token: material.token,
325
+ preset: material.preset,
326
+ flatShading: material.flatShading,
327
+ transparent: material.transparent,
328
+ emissive: material.emissive,
329
+ surface: material.surface,
330
+
331
+ materialSlot: part.materialSlot,
332
+ keepRound: part.keepRound,
333
+ sizing: part.sizing,
334
+ anchor: part.anchor,
335
+ repeat: part.repeat,
336
+ label: part.label
337
+ }) as PartModel
338
+ }