@things-factory/figure-ui 10.1.16 → 10.1.17

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 (77) hide show
  1. package/client/modeller/figure-animations.ts +12 -12
  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 +122 -25
  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 +52 -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 +25 -4
  15. package/client/pages/figure-modeller-page.ts +64 -58
  16. package/dist-client/modeller/figure-animations.d.ts +4 -4
  17. package/dist-client/modeller/figure-animations.js +10 -10
  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 +39 -0
  32. package/dist-client/modeller/figure-preview.js +112 -23
  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 +17 -15
  43. package/dist-client/modeller/figure-source.js +40 -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 +15 -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 +60 -54
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/tsconfig.tsbuildinfo +1 -1
  61. package/dist-server/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +4 -4
  63. package/test/ai-proposal-contract.test.ts +393 -12
  64. package/test/expect.ts +55 -0
  65. package/test/figure-source.test.ts +562 -0
  66. package/test/i18n-prefix-guard.test.ts +1 -1
  67. package/{client/modeller → test}/part-edits.test.ts +50 -45
  68. package/translations/en.json +31 -28
  69. package/translations/ja.json +16 -21
  70. package/translations/ko.json +41 -38
  71. package/translations/ms.json +16 -21
  72. package/translations/zh.json +16 -21
  73. package/client/modeller/figure-ask.ts +0 -265
  74. package/client/modeller/figure-source.test.ts +0 -352
  75. package/dist-client/modeller/figure-ask.d.ts +0 -40
  76. package/dist-client/modeller/figure-ask.js +0 -282
  77. package/dist-client/modeller/figure-ask.js.map +0 -1
@@ -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,41 @@ 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
+ | 'capabilities'
25
34
 
26
35
  /**
27
36
  * 부품 밖에서 바뀔 수 있는 것 전부.
28
37
  *
29
38
  * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도
30
39
  * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.
40
+ *
41
+ * 그 일이 실제로 났다(2026-09-15). `animations` · `capabilities` · `placement` 셋이 빠져 있었다.
42
+ * 후보가 애니메이션만 더해 오면 변경 목록이 **비어서** 「n건 반영」 단추가 잠기고, 저작자는
43
+ * 후보를 받지도 버리지도 못한 채 멈춘다. 다른 변경과 섞여 오면 단추는 눌리는데 애니메이션만
44
+ * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)
45
+ * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.
46
+ *
47
+ * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 여섯이 전부다. `type` 은 저장되는
48
+ * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
49
+ * 아니라 저장할 때 서버가 찍는다.
31
50
  */
32
- const FIGURE_FIELDS = ['base', 'detailLevel', 'anchor', 'styleKit'] as const
51
+ const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'capabilities'] as const
33
52
  const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
34
53
  base: 'base',
54
+ placement: 'placement',
35
55
  detailLevel: 'detail',
36
- anchor: 'anchor',
37
- styleKit: 'styleKit'
56
+ styleKit: 'styleKit',
57
+ animations: 'animations',
58
+ capabilities: 'capabilities'
38
59
  }
39
60
 
40
61
  export interface ProposalChange {