@things-factory/figure-ui 10.1.16 → 10.1.18

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 (78) hide show
  1. package/client/modeller/figure-animations.ts +331 -124
  2. package/client/modeller/figure-canvas.ts +32 -48
  3. package/client/modeller/figure-capabilities.ts +10 -10
  4. package/client/modeller/figure-inspector.ts +39 -28
  5. package/client/modeller/figure-parts.ts +1 -1
  6. package/client/modeller/figure-preview.ts +151 -41
  7. package/client/modeller/figure-report.ts +24 -6
  8. package/client/modeller/figure-settings.ts +45 -2
  9. package/client/modeller/figure-side.ts +6 -6
  10. package/client/modeller/figure-source.ts +62 -32
  11. package/client/modeller/figure-thumbnail.ts +5 -5
  12. package/client/modeller/figure-view.ts +53 -0
  13. package/client/modeller/part-edits.ts +36 -36
  14. package/client/modeller/proposal.ts +27 -4
  15. package/client/pages/figure-modeller-page.ts +65 -58
  16. package/dist-client/modeller/figure-animations.d.ts +37 -8
  17. package/dist-client/modeller/figure-animations.js +293 -122
  18. package/dist-client/modeller/figure-animations.js.map +1 -1
  19. package/dist-client/modeller/figure-canvas.d.ts +7 -7
  20. package/dist-client/modeller/figure-canvas.js +27 -40
  21. package/dist-client/modeller/figure-canvas.js.map +1 -1
  22. package/dist-client/modeller/figure-capabilities.d.ts +2 -2
  23. package/dist-client/modeller/figure-capabilities.js +9 -9
  24. package/dist-client/modeller/figure-capabilities.js.map +1 -1
  25. package/dist-client/modeller/figure-inspector.d.ts +16 -5
  26. package/dist-client/modeller/figure-inspector.js +38 -27
  27. package/dist-client/modeller/figure-inspector.js.map +1 -1
  28. package/dist-client/modeller/figure-parts.d.ts +1 -1
  29. package/dist-client/modeller/figure-parts.js +1 -1
  30. package/dist-client/modeller/figure-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-preview.d.ts +56 -6
  32. package/dist-client/modeller/figure-preview.js +132 -39
  33. package/dist-client/modeller/figure-preview.js.map +1 -1
  34. package/dist-client/modeller/figure-report.d.ts +9 -0
  35. package/dist-client/modeller/figure-report.js +23 -5
  36. package/dist-client/modeller/figure-report.js.map +1 -1
  37. package/dist-client/modeller/figure-settings.js +42 -2
  38. package/dist-client/modeller/figure-settings.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +2 -2
  40. package/dist-client/modeller/figure-side.js +5 -5
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +25 -15
  43. package/dist-client/modeller/figure-source.js +42 -21
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
  46. package/dist-client/modeller/figure-thumbnail.js +3 -3
  47. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  48. package/dist-client/modeller/figure-view.d.ts +45 -0
  49. package/dist-client/modeller/figure-view.js +27 -0
  50. package/dist-client/modeller/figure-view.js.map +1 -1
  51. package/dist-client/modeller/part-edits.d.ts +16 -16
  52. package/dist-client/modeller/part-edits.js +35 -35
  53. package/dist-client/modeller/part-edits.js.map +1 -1
  54. package/dist-client/modeller/proposal.d.ts +1 -1
  55. package/dist-client/modeller/proposal.js +16 -3
  56. package/dist-client/modeller/proposal.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.d.ts +9 -10
  58. package/dist-client/pages/figure-modeller-page.js +61 -54
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/route.d.ts +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-server/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +5 -5
  64. package/test/ai-proposal-contract.test.ts +433 -12
  65. package/test/expect.ts +55 -0
  66. package/test/figure-source.test.ts +572 -0
  67. package/test/i18n-prefix-guard.test.ts +9 -12
  68. package/{client/modeller → test}/part-edits.test.ts +50 -45
  69. package/translations/en.json +46 -35
  70. package/translations/ja.json +16 -21
  71. package/translations/ko.json +56 -45
  72. package/translations/ms.json +16 -21
  73. package/translations/zh.json +16 -21
  74. package/client/modeller/figure-ask.ts +0 -265
  75. package/client/modeller/figure-source.test.ts +0 -352
  76. package/dist-client/modeller/figure-ask.d.ts +0 -40
  77. package/dist-client/modeller/figure-ask.js +0 -282
  78. package/dist-client/modeller/figure-ask.js.map +0 -1
@@ -6,7 +6,7 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
6
6
  * ## Why this is the only place that converts
7
7
  *
8
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
9
+ * components sitting on a draft. `FigureSource` is produced from it when the
10
10
  * asset is saved or scored.
11
11
  *
12
12
  * The two use different vocabularies. The scene inherited 2D canvas terms
@@ -27,21 +27,21 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
27
27
  *
28
28
  * ## The mapping
29
29
  *
30
- * The board is the base box; its centre is the format's origin.
30
+ * The draft is the base box; its centre is the format's origin.
31
31
  *
32
32
  * format scene
33
33
  * ──────────────── ──────────────────────────────
34
- * base.x board width
35
- * base.y board depth <- height
36
- * base.z board height
34
+ * base.x draft width
35
+ * base.y draft depth <- height
36
+ * base.z draft height
37
37
  *
38
38
  * size.x width
39
39
  * size.y depth
40
40
  * size.z height
41
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
42
+ * position.x left + width/2 - draft width/2
43
+ * position.y zPos + depth/2 - draft depth/2 <- zPos is the underside
44
+ * position.z top + height/2 - draft height/2
45
45
  *
46
46
  * rotation.x rotationX (scene radians, format degrees)
47
47
  * rotation.y -rotation
@@ -73,7 +73,7 @@ import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePl
73
73
  * of the four.
74
74
  */
75
75
  /** 판 컴포넌트의 상태. 씬의 용어 그대로다. */
76
- export interface BoardModel {
76
+ export interface FigureDraft {
77
77
  /** 형식 버전. */
78
78
  version?: number
79
79
  /*
@@ -102,6 +102,14 @@ export interface BoardModel {
102
102
  * 저장하는 것만으로 **조용히 지워지기** 때문이다.
103
103
  */
104
104
  animations?: FigureSource['animations']
105
+ /**
106
+ * 값이 만드는 자세 — **애니메이션과 다른 칸이다.**
107
+ *
108
+ * 저것은 시각이 흘러 움직이는 것(도는 롤러)이고 이것은 인스턴스가 준 값이 그대로 자세가
109
+ * 되는 것(1200mm 내려온 호이스트)이다. 한동안 애니메이션 안에 `drive: 'hold'` 로 숨어
110
+ * 있었고, 그래서 「길이값을 변수로 노출하라」는 요청에 아무도 이것을 못 찾았다(ADR-0051).
111
+ */
112
+ parameters?: FigureSource['parameters']
105
113
  /**
106
114
  * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.
107
115
  *
@@ -199,7 +207,7 @@ function withoutEmpty<T extends object>(value: T): T {
199
207
  }
200
208
 
201
209
  /** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */
202
- function round4(value: number): number {
210
+ export function round4(value: number): number {
203
211
  return Math.round(value * 1e4) / 1e4
204
212
  }
205
213
 
@@ -208,10 +216,30 @@ function round4(value: number): number {
208
216
  *
209
217
  * 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.
210
218
  */
211
- export function halfOf(board: BoardModel): Vec3 {
212
- return { x: board.width / 2, y: board.depth / 2, z: board.height / 2 }
219
+ export function halfOf(draft: FigureDraft): Vec3 {
220
+ return { x: draft.width / 2, y: draft.depth / 2, z: draft.height / 2 }
213
221
  }
214
222
 
223
+ /*
224
+ * ── The placement is not in these numbers ────────────────────────────────────
225
+ *
226
+ * `placement` says which reference surface a figure of this kind attaches to — a conveyor stands
227
+ * on the floor, an OHT hangs from the ceiling. That is a fact about the figure *type*, and the
228
+ * scene acts on it when an instance is placed: `floor` puts the box on the ground, `ceiling` at
229
+ * the ceiling height less the figure's own height. Nothing about the authored shape changes.
230
+ *
231
+ * So the conversion below does not read it, and the editing canvas does not either. The box is
232
+ * always `0 … base.y` there and `zPos` is always the part's underside. Two figures with identical
233
+ * parts and different placements have identical `parts` arrays, which is what the format says.
234
+ *
235
+ * This used to be wired the other way: the canvas fed the figure's placement to the model layer's
236
+ * `state.placement`, which is a scene-wide coordinate mode ('floor' | 'space' | 'inverted') that
237
+ * flips what `zPos` names for everything in that layer. It stood in for the archetype because the
238
+ * modeller holds exactly one figure — and it cost a whole day of defects, because every number the
239
+ * author typed then meant a different face depending on a dropdown. That coordinate mode is now
240
+ * where it belongs: a workbench setting in `figure-view.ts`, applied to the preview alone.
241
+ */
242
+
215
243
  /**
216
244
  * 저작자가 보는 축이 씬의 어느 용어인가.
217
245
  *
@@ -227,34 +255,35 @@ export const AXIS_KEYS = {
227
255
  export type PlaneAxis = keyof typeof AXIS_KEYS
228
256
 
229
257
  /** 판이 그 축으로 얼마나 넓은가. */
230
- export function boardExtent(board: BoardModel, axis: PlaneAxis): number {
231
- return axis === 'x' ? board.width : axis === 'y' ? board.depth : board.height
258
+ export function draftExtent(draft: FigureDraft, axis: PlaneAxis): number {
259
+ return axis === 'x' ? draft.width : axis === 'y' ? draft.depth : draft.height
232
260
  }
233
261
 
234
262
  /** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */
235
- export function partToFigure(part: PartModel, board: BoardModel): FigurePart {
236
- return partTo(part, halfOf(board))
263
+ export function partToFigure(part: PartModel, draft: FigureDraft): FigurePart {
264
+ return partTo(part, halfOf(draft))
237
265
  }
238
266
 
239
267
  /** 부품 하나를 씬 용어로 되돌린다. */
240
- export function partFromFigure(part: FigurePart, board: BoardModel): PartModel {
241
- return partFrom(part, halfOf(board))
268
+ export function partFromFigure(part: FigurePart, draft: FigureDraft): PartModel {
269
+ return partFrom(part, halfOf(draft))
242
270
  }
243
271
 
244
272
  /** 씬 모델을 저장 형식으로 변환한다. */
245
- export function toFigureSource(board: BoardModel, parts: PartModel[]): FigureSource {
246
- const half = halfOf(board)
273
+ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {
274
+ const half = halfOf(draft)
247
275
 
248
276
  return withoutEmpty({
249
- version: board.version ?? FIGURE_SOURCE_VERSION,
250
- type: board.figureType ?? '',
251
- base: { x: board.width, y: board.depth, z: board.height },
252
- placement: board.placement,
253
- detailLevel: board.detailLevel,
254
- styleKit: board.styleKit,
277
+ version: draft.version ?? FIGURE_SOURCE_VERSION,
278
+ type: draft.figureType ?? '',
279
+ base: { x: draft.width, y: draft.depth, z: draft.height },
280
+ placement: draft.placement,
281
+ detailLevel: draft.detailLevel,
282
+ styleKit: draft.styleKit,
255
283
  parts: parts.map(part => partTo(part, half)),
256
- animations: board.animations,
257
- capabilities: board.capabilities
284
+ animations: draft.animations,
285
+ parameters: draft.parameters,
286
+ capabilities: draft.capabilities
258
287
  }) as FigureSource
259
288
  }
260
289
 
@@ -308,10 +337,10 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
308
337
  }
309
338
 
310
339
  /** 형식을 씬 모델로 편다. */
311
- export function fromFigureSource(source: FigureSource): { board: BoardModel; parts: PartModel[] } {
340
+ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
312
341
  const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
313
342
 
314
- const board = withoutEmpty({
343
+ const draft = withoutEmpty({
315
344
  version: source.version,
316
345
  width: source.base.x,
317
346
  height: source.base.z,
@@ -321,10 +350,11 @@ export function fromFigureSource(source: FigureSource): { board: BoardModel; par
321
350
  detailLevel: source.detailLevel,
322
351
  styleKit: source.styleKit,
323
352
  animations: source.animations,
353
+ parameters: source.parameters,
324
354
  capabilities: source.capabilities
325
- }) as BoardModel
355
+ }) as FigureDraft
326
356
 
327
- return { board, parts: source.parts.map(part => partFrom(part, half)) }
357
+ return { draft, parts: source.parts.map(part => partFrom(part, half)) }
328
358
  }
329
359
 
330
360
  function partFrom(part: FigurePart, half: Vec3): PartModel {
@@ -1,6 +1,6 @@
1
1
  import './figure-canvas.js'
2
2
 
3
- import type { BoardModel, PartModel } from './figure-source.js'
3
+ import type { FigureDraft, PartModel } from './figure-source.js'
4
4
 
5
5
  /**
6
6
  * 카드에 쓸 그림을 찍는다 — **숨은 자리에 저작 캔버스를 하나 더 세워서.**
@@ -28,7 +28,7 @@ const WIDTH = 480
28
28
  const HEIGHT = 360
29
29
 
30
30
  interface Shooter extends HTMLElement {
31
- board?: BoardModel
31
+ draft?: FigureDraft
32
32
  parts?: PartModel[]
33
33
  viewOnly?: boolean
34
34
  snapshot?: (width?: number, height?: number) => Promise<string | undefined>
@@ -71,10 +71,10 @@ function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
71
71
  }
72
72
 
73
73
  export async function captureThumbnail(
74
- board: BoardModel | undefined,
74
+ draft: FigureDraft | undefined,
75
75
  parts: PartModel[]
76
76
  ): Promise<string | undefined> {
77
- if (!board || !parts.length) {
77
+ if (!draft || !parts.length) {
78
78
  return undefined
79
79
  }
80
80
 
@@ -89,7 +89,7 @@ export async function captureThumbnail(
89
89
 
90
90
  const shooter = document.createElement('figure-canvas') as Shooter
91
91
  shooter.viewOnly = true
92
- shooter.board = board
92
+ shooter.draft = draft
93
93
  shooter.parts = parts
94
94
  shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
95
95
 
@@ -19,6 +19,30 @@
19
19
  * 한동안 3D 를 세울 때만 읽혔다 — things-scene 10.1.5 에서 고쳤다.
20
20
  */
21
21
  export interface ViewSettings {
22
+ /**
23
+ * 어떤 보드에 놓인 것으로 보나 — 씬 전체의 좌표 모드.
24
+ *
25
+ * **도형의 `placement` 와 다른 것이다.** 도형의 것은 「이 종류는 바닥에 서나, 천장에
26
+ * 매달리나」이고, 이것은 「지금 보고 있는 보드가 어느 쪽을 기준으로 재나」다. 바닥
27
+ * 기준 보드에 천정 기반 OHT 를 놓으면 보드는 안 뒤집히고 OHT 가 천장 높이에 매달린다.
28
+ *
29
+ * 한동안 도형의 `placement` 를 이 자리에 흘려 넣었다. 모델러에 도형이 하나뿐이라
30
+ * 그럴듯해 보였지만, 그러면 도형의 성질이 씬 전체를 뒤집는다 — 보드에서는 성립할 수가
31
+ * 없고(보드에는 도형이 여럿이다), 저작 중에는 배치를 고를 때마다 저작자가 적어 둔
32
+ * 높이가 전부 다른 숫자로 보였다.
33
+ *
34
+ * 씬의 낱말 그대로다: `floor` 바닥 기준 · `space` 중심 기준(바닥 없음) · `inverted`
35
+ * 천장 기준. **미리보기에만 먹인다** — 편집 캔버스의 `zPos` 는 저작자가 직접 적는
36
+ * 숫자라 그 뜻이 보기 설정에 따라 바뀌면 안 된다.
37
+ */
38
+ placement: 'floor' | 'space' | 'inverted'
39
+ /**
40
+ * 천장 높이 (mm). 천정 기반 도형을 미리보기에서 어디에 매달 것인가.
41
+ *
42
+ * 도형은 제가 놓일 방의 높이를 모른다. 보드는 알지만 미리보기에는 보드가 없으므로,
43
+ * 저작자가 가정할 값을 여기서 정한다.
44
+ */
45
+ ceilingHeight: number
22
46
  /** 좌표 바탕(축 가이드). 원점과 눈금을 보인다. */
23
47
  grid: boolean
24
48
  /** 축 이름표(X · Y · Z). 눈금만 두고 글자를 끌 수 있다. */
@@ -57,6 +81,32 @@ export const ENVIRONMENTS = [
57
81
  ] as const
58
82
  export type EnvironmentName = (typeof ENVIRONMENTS)[number]
59
83
 
84
+ /**
85
+ * 씬에 그대로 넣는 보기 설정 — **좌표 기준과 천장 높이는 빼고.**
86
+ *
87
+ * 나머지는 전부 `model-layer` 의 상태 이름이라 그대로 넣으면 씬이 알아서 반응한다.
88
+ * 그런데 `placement` 는 편집 캔버스에 들어가면 안 되고(거기는 늘 상자 기준이다),
89
+ * `ceilingHeight` 는 씬이 모르는 이름이다.
90
+ *
91
+ * **rest spread 로 넘기지 않으려고 이 함수가 있다.** 캔버스가 한동안
92
+ * `const { grid, ..., ...light } = view` 로 나머지를 통째로 넘겼는데, 그러면 설정을
93
+ * 하나 더할 때마다 그것이 두 화면에 **말없이** 실린다. 새로 더한 좌표 기준이 바로
94
+ * 그 길로 캔버스에 들어갈 뻔했다.
95
+ */
96
+ export function sceneLook(view: ViewSettings): Record<string, unknown> {
97
+ const { placement, ceilingHeight, ...look } = view
98
+ return look
99
+ }
100
+
101
+ /**
102
+ * 씬의 좌표 기준 — **씬의 낱말 그대로 셋이다.**
103
+ *
104
+ * 형식의 `floor` · `center` · `ceiling` 과 짝이 맞지만 철자가 다르다. 옮겨 적는 표를 두지
105
+ * 않는 것이 이 파일의 규칙이므로 씬 쪽을 쓴다. 화면에는 둘 다 보인다 — 「천장 기준
106
+ * (inverted)」.
107
+ */
108
+ export const SCENE_REFERENCES = ['floor', 'space', 'inverted'] as const
109
+
60
110
  /** 배경색 기본값. 회색이라 부품 색이 제 색으로 보인다. */
61
111
  export const WORKBENCH = '#7c7c7c'
62
112
 
@@ -67,6 +117,9 @@ export const WORKBENCH = '#7c7c7c'
67
117
  * 무엇이 설정 때문이고 무엇이 원래 그랬는지 갈리지 않는다.
68
118
  */
69
119
  export const DEFAULT_VIEW: ViewSettings = {
120
+ placement: 'floor',
121
+ /* 공장 천장의 흔한 높이. 반도체 fab 의 OHT 레일이 대체로 이 언저리다. */
122
+ ceilingHeight: 4000,
70
123
  grid: true,
71
124
  gridLabels: true,
72
125
  floor: false,
@@ -1,13 +1,13 @@
1
1
  import { EXTRUDE_PRIMITIVES, SEGMENTED_PRIMITIVES, SEGMENT_PRESETS, PART_LIMIT } from '@hatiolab/figure-model'
2
2
  import type { DetailLevel, PrimitiveKind } from '@hatiolab/figure-model'
3
3
 
4
- import { AXIS_KEYS, boardExtent } from './figure-source.js'
5
- import type { BoardModel, PartModel, PlaneAxis } from './figure-source.js'
4
+ import { AXIS_KEYS, draftExtent } from './figure-source.js'
5
+ import type { FigureDraft, PartModel, PlaneAxis } from './figure-source.js'
6
6
 
7
7
  /**
8
8
  * 부품 편집 규칙. **씬 모델의 용어로** 다룬다.
9
9
  *
10
- * 편집 중에 기준이 되는 데이터는 씬 모델이다 — 보드 위에 부품 컴포넌트가 놓인 상태.
10
+ * 편집 중에 기준이 되는 데이터는 씬 모델이다 — 기준 상자 위에 부품 컴포넌트가 놓인 상태.
11
11
  * 그래서 편집도 씬의 용어(`left`·`top`·`zPos`·`width`·`height`·`depth`)로 한다.
12
12
  * FigureSource 의 좌표계로 바꾸는 변환은 `figure-source.ts` 한 곳에서만 한다.
13
13
  *
@@ -27,33 +27,33 @@ function freeName(taken: Set<string>, base: string): string {
27
27
  }
28
28
  }
29
29
 
30
- /** 이 보드에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
31
- export function partLimit(board: BoardModel): number {
32
- return PART_LIMIT[(board.detailLevel ?? 'M') as DetailLevel]
30
+ /** 이 초안에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
31
+ export function partLimit(draft: FigureDraft): number {
32
+ return PART_LIMIT[(draft.detailLevel ?? 'M') as DetailLevel]
33
33
  }
34
34
 
35
35
  /**
36
36
  * 새 부품 하나를 만든다.
37
37
  *
38
- * 보드의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
38
+ * 기준 상자의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
39
39
  * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
40
40
  */
41
- export function newPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel {
41
+ export function newPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel {
42
42
  const taken = new Set(parts.map(part => part.name))
43
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))
44
+ const width = Math.max(1, Math.round(draft.width / 3))
45
+ const height = Math.max(1, Math.round(draft.height / 3))
46
+ const depth = Math.max(1, Math.round(draft.depth / 3))
47
47
 
48
48
  const part: PartModel = {
49
49
  type: 'figure-part',
50
50
  name: freeName(taken, primitive),
51
51
  primitive,
52
- left: Math.round((board.width - width) / 2),
53
- top: Math.round((board.height - height) / 2),
52
+ left: Math.round((draft.width - width) / 2),
53
+ top: Math.round((draft.height - height) / 2),
54
54
  width,
55
55
  height,
56
- // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 보드 바닥이다.
56
+ // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
57
57
  zPos: 0,
58
58
  depth,
59
59
  // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
@@ -79,8 +79,8 @@ export function newPart(board: BoardModel, parts: PartModel[], primitive: Primit
79
79
  return part
80
80
  }
81
81
 
82
- export function addPart(board: BoardModel, parts: PartModel[], primitive: PrimitiveKind): PartModel[] {
83
- return [...parts, newPart(board, parts, primitive)]
82
+ export function addPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel[] {
83
+ return [...parts, newPart(draft, parts, primitive)]
84
84
  }
85
85
 
86
86
  export function removePart(parts: PartModel[], index: number): PartModel[] {
@@ -133,25 +133,25 @@ function putAt(parts: PartModel[], index: number, axis: PlaneAxis, value: number
133
133
  return put(parts, index, { [AXIS_KEYS[axis].at]: value } as Partial<PartModel>)
134
134
  }
135
135
 
136
- /** 부품을 보드의 해당 축 가운데로 옮긴다. */
137
- export function centerPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
136
+ /** 부품을 기준 상자의 해당 축 가운데로 옮긴다. */
137
+ export function centerPart(draft: FigureDraft, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
138
138
  const part = parts[index]
139
139
  if (!part) return parts
140
140
 
141
- return putAt(parts, index, axis, Math.round((boardExtent(board, axis) - part[AXIS_KEYS[axis].size]) / 2))
141
+ return putAt(parts, index, axis, Math.round((draftExtent(draft, axis) - part[AXIS_KEYS[axis].size]) / 2))
142
142
  }
143
143
 
144
- /** 부품을 보드 바닥(또는 해당 축의 시작)에 붙인다. */
144
+ /** 부품을 상자 바닥(또는 해당 축의 시작)에 붙인다. */
145
145
  export function alignPartToStart(parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
146
146
  return putAt(parts, index, axis, 0)
147
147
  }
148
148
 
149
- /** 부품을 보드의 끝에 붙인다. */
150
- export function alignPartToEnd(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
149
+ /** 부품을 상자의 끝에 붙인다. */
150
+ export function alignPartToEnd(draft: FigureDraft, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
151
151
  const part = parts[index]
152
152
  if (!part) return parts
153
153
 
154
- return putAt(parts, index, axis, Math.round(boardExtent(board, axis) - part[AXIS_KEYS[axis].size]))
154
+ return putAt(parts, index, axis, Math.round(draftExtent(draft, axis) - part[AXIS_KEYS[axis].size]))
155
155
  }
156
156
 
157
157
  /**
@@ -160,22 +160,22 @@ export function alignPartToEnd(board: BoardModel, parts: PartModel[], index: num
160
160
  * 다리 넷, 팔 둘 같은 형상을 이것 없이 만들면 고생한다. 이름은 **어느 쪽인지 알 수
161
161
  * 있게** 붙인다 — `leg` 를 x 축으로 대칭하면 `leg-x`.
162
162
  */
163
- export function mirrorPart(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
163
+ export function mirrorPart(draft: FigureDraft, parts: PartModel[], index: number, axis: PlaneAxis): PartModel[] {
164
164
  const part = parts[index]
165
- if (!part || !canMirror(board, parts, index, axis)) return parts
165
+ if (!part || !canMirror(draft, parts, index, axis)) return parts
166
166
 
167
167
  const copy = JSON.parse(JSON.stringify(part)) as PartModel
168
168
  copy.name = freeName(new Set(parts.map(p => p.name)), `${part.name}-${axis}`)
169
169
 
170
170
  const next = [...parts]
171
171
  next.splice(index + 1, 0, copy)
172
- return putAt(next, index + 1, axis, mirroredAt(board, part, axis))
172
+ return putAt(next, index + 1, axis, mirroredAt(draft, part, axis))
173
173
  }
174
174
 
175
- /** 대칭했을 때의 위치. 보드의 해당 축 안에서 뒤집는다. */
176
- function mirroredAt(board: BoardModel, part: PartModel, axis: PlaneAxis): number {
175
+ /** 대칭했을 때의 위치. 상자의 해당 축 안에서 뒤집는다. */
176
+ function mirroredAt(draft: FigureDraft, part: PartModel, axis: PlaneAxis): number {
177
177
  const { at, size } = AXIS_KEYS[axis]
178
- return Math.round(boardExtent(board, axis) - part[at] - part[size])
178
+ return Math.round(draftExtent(draft, axis) - part[at] - part[size])
179
179
  }
180
180
 
181
181
  /**
@@ -185,24 +185,24 @@ function mirroredAt(board: BoardModel, part: PartModel, axis: PlaneAxis): number
185
185
  * 아무 변화가 없어 보이는데 부품 수만 늘고, 겹친 면에서 z-fighting 이 일어난다.
186
186
  * 만들어 놓고 알리는 대신 미리 막는다.
187
187
  */
188
- export function canMirror(board: BoardModel, parts: PartModel[], index: number, axis: PlaneAxis): boolean {
188
+ export function canMirror(draft: FigureDraft, parts: PartModel[], index: number, axis: PlaneAxis): boolean {
189
189
  const part = parts[index]
190
190
  if (!part) return false
191
191
 
192
- return mirroredAt(board, part, axis) !== Math.round(part[AXIS_KEYS[axis].at])
192
+ return mirroredAt(draft, part, axis) !== Math.round(part[AXIS_KEYS[axis].at])
193
193
  }
194
194
 
195
195
  /**
196
196
  * 부품의 위치와 크기를 격자에 맞춘다.
197
197
  *
198
- * 격자 간격은 보드 크기에서 계산한다 — 고정 값이면 작은 형상에서는 너무 크고 큰
198
+ * 격자 간격은 상자 크기에서 계산한다 — 고정 값이면 작은 형상에서는 너무 크고 큰
199
199
  * 형상에서는 너무 잘다. 3D 바닥 격자와 같은 계산이라 화면에서 보이는 것과 일치한다.
200
200
  */
201
- export function snapPart(board: BoardModel, parts: PartModel[], index: number): PartModel[] {
201
+ export function snapPart(draft: FigureDraft, parts: PartModel[], index: number): PartModel[] {
202
202
  const part = parts[index]
203
203
  if (!part) return parts
204
204
 
205
- const step = gridStep(board)
205
+ const step = gridStep(draft)
206
206
  const round = (value: number) => Math.round(value / step) * step
207
207
 
208
208
  return put(parts, index, {
@@ -223,8 +223,8 @@ export function snapPart(board: BoardModel, parts: PartModel[], index: number):
223
223
  * (바닥 160 x 160, 높이 1100)는 평면 기준으로 간격이 20 이 나와서, 옆에서 보면 높이가
224
224
  * 격자 55 칸이 된다. 격자는 크기를 가늠하는 자이므로 모델 전체에 대해 읽혀야 한다.
225
225
  */
226
- export function gridStep(board: BoardModel): number {
227
- return niceStep(Math.max(board.width, board.height, board.depth))
226
+ export function gridStep(draft: FigureDraft): number {
227
+ return niceStep(Math.max(draft.width, draft.height, draft.depth))
228
228
  }
229
229
 
230
230
  /**
@@ -21,20 +21,43 @@ import type { FigurePart, FigureSource } from '@hatiolab/figure-model'
21
21
  * 기준 치수와 디테일 등급도 목록에 넣는다. 부품만 골라 받으면 새 부품이 옛 기준
22
22
  * 상자 밖으로 나가 **검증에 걸릴 수 있다** — 무엇을 함께 받아야 하는지 보여야 한다.
23
23
  */
24
- export type ChangeKind = 'added' | 'changed' | 'removed' | 'base' | 'detail' | 'anchor' | 'styleKit'
24
+ export type ChangeKind =
25
+ | 'added'
26
+ | 'changed'
27
+ | 'removed'
28
+ | 'base'
29
+ | 'placement'
30
+ | 'detail'
31
+ | 'styleKit'
32
+ | 'animations'
33
+ | 'parameters'
34
+ | 'capabilities'
25
35
 
26
36
  /**
27
37
  * 부품 밖에서 바뀔 수 있는 것 전부.
28
38
  *
29
39
  * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도
30
40
  * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.
41
+ *
42
+ * 그 일이 실제로 났다(2026-09-15). `animations` · `capabilities` · `placement` 셋이 빠져 있었다.
43
+ * 후보가 애니메이션만 더해 오면 변경 목록이 **비어서** 「n건 반영」 단추가 잠기고, 저작자는
44
+ * 후보를 받지도 버리지도 못한 채 멈춘다. 다른 변경과 섞여 오면 단추는 눌리는데 애니메이션만
45
+ * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)
46
+ * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.
47
+ *
48
+ * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 여섯이 전부다. `type` 은 저장되는
49
+ * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
50
+ * 아니라 저장할 때 서버가 찍는다.
31
51
  */
32
- const FIGURE_FIELDS = ['base', 'detailLevel', 'anchor', 'styleKit'] as const
52
+ const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities'] as const
33
53
  const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
34
54
  base: 'base',
55
+ placement: 'placement',
35
56
  detailLevel: 'detail',
36
- anchor: 'anchor',
37
- styleKit: 'styleKit'
57
+ styleKit: 'styleKit',
58
+ animations: 'animations',
59
+ parameters: 'parameters',
60
+ capabilities: 'capabilities'
38
61
  }
39
62
 
40
63
  export interface ProposalChange {