@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.
- package/client/modeller/figure-animations.ts +12 -12
- package/client/modeller/figure-canvas.ts +32 -48
- package/client/modeller/figure-capabilities.ts +10 -10
- package/client/modeller/figure-inspector.ts +39 -28
- package/client/modeller/figure-parts.ts +1 -1
- package/client/modeller/figure-preview.ts +122 -25
- package/client/modeller/figure-report.ts +24 -6
- package/client/modeller/figure-settings.ts +45 -2
- package/client/modeller/figure-side.ts +6 -6
- package/client/modeller/figure-source.ts +52 -32
- package/client/modeller/figure-thumbnail.ts +5 -5
- package/client/modeller/figure-view.ts +53 -0
- package/client/modeller/part-edits.ts +36 -36
- package/client/modeller/proposal.ts +25 -4
- package/client/pages/figure-modeller-page.ts +64 -58
- package/dist-client/modeller/figure-animations.d.ts +4 -4
- package/dist-client/modeller/figure-animations.js +10 -10
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +7 -7
- package/dist-client/modeller/figure-canvas.js +27 -40
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-capabilities.d.ts +2 -2
- package/dist-client/modeller/figure-capabilities.js +9 -9
- package/dist-client/modeller/figure-capabilities.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +16 -5
- package/dist-client/modeller/figure-inspector.js +38 -27
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +1 -1
- package/dist-client/modeller/figure-parts.js +1 -1
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +39 -0
- package/dist-client/modeller/figure-preview.js +112 -23
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-report.d.ts +9 -0
- package/dist-client/modeller/figure-report.js +23 -5
- package/dist-client/modeller/figure-report.js.map +1 -1
- package/dist-client/modeller/figure-settings.js +42 -2
- package/dist-client/modeller/figure-settings.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +2 -2
- package/dist-client/modeller/figure-side.js +5 -5
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +17 -15
- package/dist-client/modeller/figure-source.js +40 -21
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
- package/dist-client/modeller/figure-thumbnail.js +3 -3
- package/dist-client/modeller/figure-thumbnail.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +45 -0
- package/dist-client/modeller/figure-view.js +27 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/part-edits.d.ts +16 -16
- package/dist-client/modeller/part-edits.js +35 -35
- package/dist-client/modeller/part-edits.js.map +1 -1
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +15 -3
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +9 -10
- package/dist-client/pages/figure-modeller-page.js +60 -54
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/ai-proposal-contract.test.ts +393 -12
- package/test/expect.ts +55 -0
- package/test/figure-source.test.ts +562 -0
- package/test/i18n-prefix-guard.test.ts +1 -1
- package/{client/modeller → test}/part-edits.test.ts +50 -45
- package/translations/en.json +31 -28
- package/translations/ja.json +16 -21
- package/translations/ko.json +41 -38
- package/translations/ms.json +16 -21
- package/translations/zh.json +16 -21
- package/client/modeller/figure-ask.ts +0 -265
- package/client/modeller/figure-source.test.ts +0 -352
- package/dist-client/modeller/figure-ask.d.ts +0 -40
- package/dist-client/modeller/figure-ask.js +0 -282
- package/dist-client/modeller/figure-ask.js.map +0 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { describe, it, test } from 'node:test'
|
|
4
|
+
|
|
5
|
+
import { expect } from './expect.ts'
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from 'fs'
|
|
8
|
+
import { dirname, join } from 'path'
|
|
9
|
+
|
|
10
|
+
import { toFigureSource, fromFigureSource, partToFigure, partFromFigure } from '../client/modeller/figure-source.ts'
|
|
11
|
+
import type { FigureDraft, PartModel } from '../client/modeller/figure-source.ts'
|
|
12
|
+
import type { FigurePlacement, FigureSource } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 씬 모델 ↔ `FigureSource`.
|
|
16
|
+
*
|
|
17
|
+
* 저작 중의 정본은 씬 모델이고, 형식은 저장하거나 판정할 때 접어서 만든다. 두 어휘가
|
|
18
|
+
* 다르므로 **옮기는 일이 한 곳에만** 있어야 하고, 그 한 곳이 맞는지를 여기서 못 박는다.
|
|
19
|
+
*
|
|
20
|
+
* 가장 중요한 것은 왕복이다. 접었다 펴면 같아야 하고 펴서 접어도 같아야 한다 —
|
|
21
|
+
* 어느 한쪽에서만 맞으면 AI 가 제안해 온 정본을 편집하는 순간 값이 샌다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 판 3000 x 2000 x 800 — 세 축이 다 달라야 축을 바꿔 써도 시험이 잡는다. */
|
|
25
|
+
const DRAFT: FigureDraft = {
|
|
26
|
+
width: 3000,
|
|
27
|
+
height: 2000,
|
|
28
|
+
depth: 800,
|
|
29
|
+
figureType: 'AGV',
|
|
30
|
+
detailLevel: 'L'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PART: PartModel = {
|
|
34
|
+
type: 'figure-part',
|
|
35
|
+
name: 'deck',
|
|
36
|
+
primitive: 'cube',
|
|
37
|
+
left: 1100,
|
|
38
|
+
top: 700,
|
|
39
|
+
width: 800,
|
|
40
|
+
height: 600,
|
|
41
|
+
zPos: 300,
|
|
42
|
+
depth: 200,
|
|
43
|
+
token: 'palette.primary'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('접기 — 씬 모델을 형식으로', () => {
|
|
47
|
+
it('판이 기준 상자가 된다 — 높이는 판의 depth 다', () => {
|
|
48
|
+
const { base } = toFigureSource(DRAFT, [])
|
|
49
|
+
expect(base).toEqual({ x: 3000, y: 800, z: 2000 })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('부품 자리가 판 중심 기준이 된다', () => {
|
|
53
|
+
const [part] = toFigureSource(DRAFT, [PART]).parts
|
|
54
|
+
|
|
55
|
+
// 평면 중심 (1100+400, 700+300) = (1500, 1000), 판 중심 (1500, 1000) → 원점
|
|
56
|
+
expect(part.transform.position.x).toBe(0)
|
|
57
|
+
expect(part.transform.position.z).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('zPos 는 밑면이다 — 중심으로 올려 적는다', () => {
|
|
61
|
+
const [part] = toFigureSource(DRAFT, [PART]).parts
|
|
62
|
+
|
|
63
|
+
// 밑면 300 + 두께 200/2 = 중심 400. 판 높이 800 의 절반을 빼면 0
|
|
64
|
+
expect(part.transform.position.y).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('가운데를 벗어난 부품이 그만큼 어긋나 적힌다', () => {
|
|
68
|
+
const moved = { ...PART, left: 1200, top: 900, zPos: 500 }
|
|
69
|
+
const [part] = toFigureSource(DRAFT, [moved]).parts
|
|
70
|
+
|
|
71
|
+
expect(part.transform.position).toEqual({ x: 100, y: 200, z: 200 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('크기가 축 이름으로 바뀐다 — 두께가 y 다', () => {
|
|
75
|
+
const [part] = toFigureSource(DRAFT, [PART]).parts
|
|
76
|
+
expect(part.transform.size).toEqual({ x: 800, y: 200, z: 600 })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
씬은 라디안을 쓰고 이름이 뒤섞여 있다 — rotationY 가 three 의 z 로 간다.
|
|
81
|
+
형식은 도를 쓰고 축 이름이 그대로다.
|
|
82
|
+
*/
|
|
83
|
+
it('회전이 도로 바뀌고 축이 제자리를 찾는다', () => {
|
|
84
|
+
const turned = { ...PART, rotationX: Math.PI / 6, rotation: Math.PI / 2, rotationY: Math.PI }
|
|
85
|
+
const [part] = toFigureSource(DRAFT, [turned]).parts
|
|
86
|
+
|
|
87
|
+
expect(part.transform.rotation).toEqual({ x: 30, y: -90, z: 180 })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('회전이 없으면 필드를 두지 않는다', () => {
|
|
91
|
+
const [part] = toFigureSource(DRAFT, [PART]).parts
|
|
92
|
+
expect(part.transform.rotation).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('재질이 한 덩어리로 모인다', () => {
|
|
96
|
+
const [part] = toFigureSource(DRAFT, [{ ...PART, preset: 'metal', flatShading: false }]).parts
|
|
97
|
+
expect(part.material).toEqual({ token: 'palette.primary', preset: 'metal', flatShading: false })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('단면 값이 shape 으로 모인다', () => {
|
|
101
|
+
const [part] = toFigureSource(DRAFT, [{ ...PART, primitive: 'rect', round: 40 }]).parts
|
|
102
|
+
expect(part.shape).toEqual({ round: 40 })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('단면 값이 없으면 shape 을 두지 않는다', () => {
|
|
106
|
+
expect(toFigureSource(DRAFT, [PART]).parts[0].shape).toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('펴기 — 형식을 씬 모델로', () => {
|
|
111
|
+
const SOURCE: FigureSource = {
|
|
112
|
+
type: 'AGV',
|
|
113
|
+
base: { x: 3000, y: 800, z: 2000 },
|
|
114
|
+
detailLevel: 'L',
|
|
115
|
+
parts: [
|
|
116
|
+
{
|
|
117
|
+
name: 'deck',
|
|
118
|
+
primitive: 'cube',
|
|
119
|
+
transform: { position: { x: 100, y: 200, z: 200 }, size: { x: 800, y: 200, z: 600 } },
|
|
120
|
+
material: { token: 'palette.primary' }
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it('기준 상자가 판이 된다', () => {
|
|
126
|
+
const { draft } = fromFigureSource(SOURCE)
|
|
127
|
+
expect(draft).toMatchObject({ width: 3000, height: 2000, depth: 800 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('중심 기준 자리가 씬의 모서리·밑면으로 바뀐다', () => {
|
|
131
|
+
const [part] = fromFigureSource(SOURCE).parts
|
|
132
|
+
expect(part).toMatchObject({ left: 1200, top: 900, zPos: 500 })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('부품이 씬 타입을 갖는다 — 판에 그대로 놓을 수 있어야 한다', () => {
|
|
136
|
+
expect(fromFigureSource(SOURCE).parts[0].type).toBe('figure-part')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
왕복.
|
|
142
|
+
|
|
143
|
+
이것이 이 모듈의 존재 이유다. 한쪽에서만 맞으면 AI 가 제안해 온 정본을 펴서
|
|
144
|
+
편집하고 다시 접는 순간 값이 샌다 — 그것도 조용히.
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* 형식이 가진 것을 되도록 다 담은 정본. 안 쓰이는 필드도 넣는다.
|
|
148
|
+
*
|
|
149
|
+
* describe 밖에 둔다 — 아래 「픽스처가 형식을 다 담는다」가 이것을 형식의 필드 목록과
|
|
150
|
+
* 맞대어 본다.
|
|
151
|
+
*/
|
|
152
|
+
/** 형식이 가진 것을 되도록 다 담은 정본. 안 쓰이는 필드도 넣는다. */
|
|
153
|
+
const FULL: FigureSource = {
|
|
154
|
+
version: 1,
|
|
155
|
+
type: 'AGV',
|
|
156
|
+
base: { x: 3000, y: 800, z: 2000 },
|
|
157
|
+
placement: 'floor',
|
|
158
|
+
detailLevel: 'L',
|
|
159
|
+
styleKit: 'factory',
|
|
160
|
+
parts: [
|
|
161
|
+
{
|
|
162
|
+
name: 'deck',
|
|
163
|
+
primitive: 'cube',
|
|
164
|
+
transform: { position: { x: 100, y: 200, z: 200 }, size: { x: 800, y: 200, z: 600 } },
|
|
165
|
+
material: { token: 'palette.primary', preset: 'metal', flatShading: true }
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'wheel',
|
|
169
|
+
primitive: 'cylinder',
|
|
170
|
+
segments: 24,
|
|
171
|
+
transform: {
|
|
172
|
+
position: { x: -700, y: -260, z: 420 },
|
|
173
|
+
size: { x: 180, y: 70, z: 180 },
|
|
174
|
+
rotation: { z: 90 }
|
|
175
|
+
},
|
|
176
|
+
material: { token: 'palette.dark', preset: 'rubber' }
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'panel',
|
|
180
|
+
primitive: 'rect',
|
|
181
|
+
// 속을 판 단면. 한 겹 안이라 처음 판 시험이 못 봤던 자리다.
|
|
182
|
+
shape: { round: 40, hollow: { wall: 8, floor: 4 } },
|
|
183
|
+
transform: {
|
|
184
|
+
position: { x: 0, y: 260, z: -500 },
|
|
185
|
+
size: { x: 400, y: 30, z: 200 },
|
|
186
|
+
rotation: { x: 15, y: -30, z: 45 }
|
|
187
|
+
},
|
|
188
|
+
material: { token: 'palette.accent', transparent: true },
|
|
189
|
+
// 아직 아무 일도 안 하는 필드들. 잃어버리면 안 된다.
|
|
190
|
+
materialSlot: 'cap',
|
|
191
|
+
keepRound: false,
|
|
192
|
+
sizing: 'fixed',
|
|
193
|
+
anchor: { x: 'span', y: 'max', z: 'center' },
|
|
194
|
+
label: { name: 'rpm', source: 'speed', when: 'zoomed' }
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'foot',
|
|
198
|
+
primitive: 'polygon',
|
|
199
|
+
shape: {
|
|
200
|
+
path: [
|
|
201
|
+
{ x: -60, y: -40 },
|
|
202
|
+
{ x: 60, y: -40 },
|
|
203
|
+
{ x: 0, y: 40 }
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
transform: { position: { x: 900, y: -340, z: -600 }, size: { x: 120, y: 80, z: 80 } },
|
|
207
|
+
material: { token: 'palette.neutral' },
|
|
208
|
+
sizing: 'repeat',
|
|
209
|
+
repeat: { axis: 'x', pitch: 200 }
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
/*
|
|
213
|
+
물건을 놓는 자리이면서 드나드는 문이다 — AGV 의 상판이 그렇다. **그려지지 않으므로**
|
|
214
|
+
재질이 없다.
|
|
215
|
+
|
|
216
|
+
그리지 않는 것이라 화면으로는 왕복에서 누락됐는지 알 수 없다. 자리를 잃어버린 도형은
|
|
217
|
+
멀쩡해 보이면서 아무것도 받지 못한다.
|
|
218
|
+
*/
|
|
219
|
+
name: 'seat',
|
|
220
|
+
primitive: 'cube',
|
|
221
|
+
transform: { position: { x: 0, y: 420, z: 0 }, size: { x: 700, y: 500, z: 500 } },
|
|
222
|
+
capability: { roles: ['slot', 'port-in', 'port-out'], accepts: ['PALLET'], capacity: 2 }
|
|
223
|
+
}
|
|
224
|
+
],
|
|
225
|
+
/* 이 도형이 지는 능력. 부품이 아니라 도형 전체의 것이다. */
|
|
226
|
+
capabilities: ['FlowNode'],
|
|
227
|
+
/*
|
|
228
|
+
움직임. 편집면이 아직 안 만지는 자리이며, **바로 그래서 여기 있다.**
|
|
229
|
+
|
|
230
|
+
이 픽스처가 「형식이 가진 것을 되도록 다 담은 정본」인데 clip 이 빠져 있었고, 그
|
|
231
|
+
사이 `toFigureSource` 가 clip 을 안 실었다. 시험은 초록이었다 — 픽스처에 없는 것은
|
|
232
|
+
시험이 못 본다. 표본 롤러에 clip 을 적고 미리보기에서 아무것도 안 돌고 나서야 알았다.
|
|
233
|
+
*/
|
|
234
|
+
animations: [
|
|
235
|
+
{
|
|
236
|
+
name: 'spin',
|
|
237
|
+
drive: 'loop',
|
|
238
|
+
channels: [
|
|
239
|
+
{
|
|
240
|
+
target: 'wheel',
|
|
241
|
+
path: 'rotation',
|
|
242
|
+
pivot: { x: 0, y: 0, z: 0 },
|
|
243
|
+
keys: [
|
|
244
|
+
{ at: 0, value: { x: 0, y: 0, z: 0 } },
|
|
245
|
+
{ at: 1, value: { x: 0, y: 360, z: 0 } }
|
|
246
|
+
]
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe('왕복해도 같다', () => {
|
|
254
|
+
|
|
255
|
+
it('펴서 접으면 그대로다', () => {
|
|
256
|
+
const { draft, parts } = fromFigureSource(FULL)
|
|
257
|
+
expect(toFigureSource(draft, parts)).toEqual(FULL)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('접어서 펴면 그대로다', () => {
|
|
261
|
+
const { draft, parts } = fromFigureSource(FULL)
|
|
262
|
+
const again = fromFigureSource(toFigureSource(draft, parts))
|
|
263
|
+
|
|
264
|
+
expect(again.draft).toEqual(draft)
|
|
265
|
+
expect(again.parts).toEqual(parts)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('두 번 왕복해도 값이 밀리지 않는다 — 라디안·도를 오가도', () => {
|
|
269
|
+
let source = FULL
|
|
270
|
+
for (let i = 0; i < 5; i++) {
|
|
271
|
+
const { draft, parts } = fromFigureSource(source)
|
|
272
|
+
source = toFigureSource(draft, parts)
|
|
273
|
+
}
|
|
274
|
+
expect(source).toEqual(FULL)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('움직임도 살아남는다 — 열었다 저장하는 것만으로 지워지던 자리다', () => {
|
|
278
|
+
const { draft, parts } = fromFigureSource(FULL)
|
|
279
|
+
|
|
280
|
+
expect(toFigureSource(draft, parts).animations).toEqual(FULL.animations)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('안 쓰이는 필드도 살아남는다 — 조용히 사라지면 저작자가 알 길이 없다', () => {
|
|
284
|
+
const { draft, parts } = fromFigureSource(FULL)
|
|
285
|
+
const panel = toFigureSource(draft, parts).parts.find(part => part.name === 'panel')!
|
|
286
|
+
|
|
287
|
+
expect(panel.materialSlot).toBe('cap')
|
|
288
|
+
expect(panel.sizing).toBe('fixed')
|
|
289
|
+
expect(panel.label).toEqual({ name: 'rpm', source: 'speed', when: 'zoomed' })
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
픽스처가 형식을 따라가는지.
|
|
295
|
+
|
|
296
|
+
**이 파일에서 필드가 네 번 샜다** — `ports` · `animations` · `anchor` · `shape.hollow`.
|
|
297
|
+
네 번 다 왕복 시험은 초록이었다. 위의 `FULL` 이 「형식이 가진 것을 되도록 다 담은
|
|
298
|
+
정본」이라고 적혀 있었지만 실제로는 그때그때 손으로 채운 것이라, 형식에 필드가 늘면
|
|
299
|
+
픽스처는 그대로 있고 시험은 늘어난 자리를 보지 못했다.
|
|
300
|
+
|
|
301
|
+
네 번째 것은 **한 겹 안**이라 최상위만 세던 첫 판이 놓쳤다. 그래서 픽스처의 키를
|
|
302
|
+
깊이 상관없이 다 모아 놓고 맞댄다.
|
|
303
|
+
|
|
304
|
+
그래서 픽스처를 믿지 않고 **형식이 스스로 밝힌 필드 목록**을 읽는다. 형식이 붙은
|
|
305
|
+
타입 선언(`dist/types.d.ts`)이 정본이고, 거기 적힌 이름이 픽스처에 없으면 여기서
|
|
306
|
+
깨진다. 다음 번 필드는 화면이 아니라 이 시험이 먼저 잡는다.
|
|
307
|
+
*/
|
|
308
|
+
describe('픽스처가 형식을 다 담는다', () => {
|
|
309
|
+
/**
|
|
310
|
+
* 형식이 붙여 보낸 타입 선언 파일.
|
|
311
|
+
*
|
|
312
|
+
* `require.resolve` 로는 못 잡는다 — jest 의 resolver 가 `.d.ts` 를 모듈로 보지
|
|
313
|
+
* 않는다. 올라가면서 찾고, 못 찾으면 조용히 건너뛰지 않고 여기서 깨진다.
|
|
314
|
+
*/
|
|
315
|
+
function declarationPath(): string {
|
|
316
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
317
|
+
for (let at = here; at !== dirname(at); at = dirname(at)) {
|
|
318
|
+
const candidate = join(at, 'node_modules/@hatiolab/figure-model/dist/types.d.ts')
|
|
319
|
+
if (existsSync(candidate)) return candidate
|
|
320
|
+
}
|
|
321
|
+
throw new Error('figure-model 의 타입 선언을 못 찾았다 — 이 시험은 그것이 정본이다')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 붙은 타입 선언에서 한 인터페이스의 필드 이름을 뽑는다. */
|
|
325
|
+
function fieldsOf(name: string): string[] {
|
|
326
|
+
const declared = readFileSync(declarationPath(), 'utf-8')
|
|
327
|
+
const start = declared.indexOf(`export interface ${name} {`)
|
|
328
|
+
expect(start).toBeGreaterThan(-1)
|
|
329
|
+
|
|
330
|
+
// 중첩이 없는 평평한 인터페이스다. 다음 닫는 중괄호가 끝이다.
|
|
331
|
+
const body = declared.slice(start, declared.indexOf('\n}', start))
|
|
332
|
+
return [...body.matchAll(/^\s{4}(\w+)\??:/gm)].map(m => m[1])
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 픽스처가 쓴 키를 깊이 상관없이 모은다 — 흘린 필드가 한 겹 안에 있었다. */
|
|
336
|
+
function keysDeep(value: unknown, into = new Set<string>()): Set<string> {
|
|
337
|
+
if (Array.isArray(value)) value.forEach(one => keysDeep(one, into))
|
|
338
|
+
else if (value && typeof value === 'object') {
|
|
339
|
+
for (const [key, one] of Object.entries(value)) {
|
|
340
|
+
into.add(key)
|
|
341
|
+
keysDeep(one, into)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return into
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const WRITTEN = keysDeep(FULL)
|
|
348
|
+
|
|
349
|
+
/*
|
|
350
|
+
`fold` 가 실제로 낱개로 푸는 것들이다. 통째로 나르는 구조(`transform` 처럼)는 필드가
|
|
351
|
+
늘어도 안 새므로 여기 없다.
|
|
352
|
+
*/
|
|
353
|
+
for (const name of ['FigureSource', 'FigurePart', 'PartShape', 'HollowSpec']) {
|
|
354
|
+
it(`${name} 가 가진 모든 필드가 왕복 픽스처에 있다`, () => {
|
|
355
|
+
expect(fieldsOf(name).filter(field => !WRITTEN.has(field))).toEqual([])
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
/*
|
|
361
|
+
* ── The placement is a height, not a flip ────────────────────────────────────
|
|
362
|
+
*
|
|
363
|
+
* `placement` says which reference surface a figure of this kind attaches to. It is a fact about
|
|
364
|
+
* the figure *type*, and what acts on it is the scene placing an instance: a floor figure stands on
|
|
365
|
+
* the ground, a ceiling one hangs at ceiling height less its own. `operato-scene`'s archetype says
|
|
366
|
+
* the same — *ceiling: zPos derived from ceiling minus depth* (`archetypes.ts`).
|
|
367
|
+
*
|
|
368
|
+
* **Nothing about the authored shape depends on it.** The format measures every part from the box
|
|
369
|
+
* centre, so two figures with identical parts and different placements have identical `parts`.
|
|
370
|
+
*
|
|
371
|
+
* It was wired the other way for a while: the editing canvas fed the figure's placement to the
|
|
372
|
+
* model layer's `state.placement`, a scene-wide coordinate mode that flips what `zPos` names for
|
|
373
|
+
* everything in that layer. It stood in for the archetype because the modeller holds one figure —
|
|
374
|
+
* and every number the author had typed then meant a different face depending on a dropdown, while
|
|
375
|
+
* what got saved never changed. These four tests hold that line.
|
|
376
|
+
*/
|
|
377
|
+
test('배치를 바꿔도 부품의 자리는 한 글자도 안 바뀐다', () => {
|
|
378
|
+
const source: FigureSource = {
|
|
379
|
+
type: 'OHT_TEST',
|
|
380
|
+
base: { x: 2, y: 3, z: 2 },
|
|
381
|
+
parts: [
|
|
382
|
+
/* 두께가 서로 달라야 한다 — 두께에 비례하는 어긋남은 통째로 옮기는 것과 달리 형상을 벌린다. */
|
|
383
|
+
{ name: 'rail', primitive: 'cube', transform: { position: { x: 0, y: 1.425, z: 0 }, size: { x: 2, y: 0.15, z: 0.4 } }, material: { token: 'palette.primary' } },
|
|
384
|
+
{ name: 'belt', primitive: 'cube', transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 0.1, y: 1, z: 0.1 } }, material: { token: 'palette.neutral' } }
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const authored = JSON.stringify(source.parts)
|
|
389
|
+
|
|
390
|
+
for (const placement of ['floor', 'ceiling', 'center'] as const) {
|
|
391
|
+
const { draft, parts } = fromFigureSource({ ...source, placement })
|
|
392
|
+
assert.equal(
|
|
393
|
+
JSON.stringify(toFigureSource(draft, parts).parts),
|
|
394
|
+
authored,
|
|
395
|
+
`${placement}: 배치가 저작한 자리를 바꿨다`
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('편집 캔버스의 zPos 는 배치와 무관하게 밑면이다', () => {
|
|
401
|
+
/*
|
|
402
|
+
저작자가 인스펙터에 적는 수다. 그 뜻이 드롭다운에 따라 밑면↔윗면으로 바뀌면, 아무것도
|
|
403
|
+
안 고쳤는데 모든 부품의 높이가 달라진 것으로 보인다. OHT 에서 실제로 그랬다 —
|
|
404
|
+
overhead-rail 이 1.35 에서 -1.5 가 되었다.
|
|
405
|
+
*/
|
|
406
|
+
const part: PartModel = { type: 'figure-part', name: 'rail', primitive: 'cube', left: 0, top: 0, width: 2, height: 0.4, zPos: 1.35, depth: 0.15 }
|
|
407
|
+
const draft: FigureDraft = { width: 2, height: 2, depth: 3 }
|
|
408
|
+
|
|
409
|
+
for (const placement of [undefined, 'floor', 'ceiling', 'center'] as const) {
|
|
410
|
+
const written = partToFigure(part, { ...draft, placement })
|
|
411
|
+
/* 밑면 1.35 + 두께 0.15/2 = 중심 1.425. 상자 높이 3 의 절반을 빼면 -0.075. */
|
|
412
|
+
assert.equal(written.transform.position.y, -0.075, `${placement}: 접는 쪽이 배치를 봤다`)
|
|
413
|
+
assert.equal(partFromFigure(written, { ...draft, placement }).zPos, 1.35, `${placement}: 펴는 쪽이 배치를 봤다`)
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
describe('배치는 씬의 좌표 모드가 아니다', () => {
|
|
418
|
+
const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
|
|
419
|
+
|
|
420
|
+
/*
|
|
421
|
+
소스를 읽는 검사다. Lit 컴포넌트가 document 없이 안 뜨는 자리라 그렇고, 무엇보다
|
|
422
|
+
**없어야 하는 것이 없는지**를 묻는 검사는 호출로는 물을 수가 없다.
|
|
423
|
+
*/
|
|
424
|
+
it('변환이 좌표 모드를 아예 모른다', () => {
|
|
425
|
+
const source = read('../client/modeller/figure-source.ts')
|
|
426
|
+
|
|
427
|
+
assert.doesNotMatch(source, /zPosOffset|toScenePlacement/, '변환에 좌표 모드가 다시 들어왔다')
|
|
428
|
+
assert.doesNotMatch(
|
|
429
|
+
source,
|
|
430
|
+
/placement.*=>.*'inverted'|'inverted'.*:/,
|
|
431
|
+
'씬의 좌표 낱말이 변환에 들어왔다'
|
|
432
|
+
)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('편집 캔버스가 씬에 배치를 안 싣는다', () => {
|
|
436
|
+
const source = read('../client/modeller/figure-canvas.ts')
|
|
437
|
+
|
|
438
|
+
assert.doesNotMatch(
|
|
439
|
+
source,
|
|
440
|
+
/placement:/,
|
|
441
|
+
'편집 캔버스가 배치를 씬에 실으면 저작자가 적은 높이의 뜻이 드롭다운마다 바뀐다'
|
|
442
|
+
)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('setPlacement 가 부품을 안 건드린다', () => {
|
|
446
|
+
const source = read('../client/modeller/figure-inspector.ts')
|
|
447
|
+
const body = source.slice(source.indexOf('private setPlacement'), source.indexOf('private setPlacement') + 500)
|
|
448
|
+
|
|
449
|
+
assert.match(body, /detail: \{ draft \}/, '배치는 필드 하나만 쓴다')
|
|
450
|
+
assert.doesNotMatch(body, /zPos/, '배치를 바꾸면서 부품 높이를 다시 계산하면 안 된다')
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
describe('배치가 미리보기에서 높이가 된다', () => {
|
|
455
|
+
const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
|
|
456
|
+
const preview = read('../client/modeller/figure-preview.ts')
|
|
457
|
+
|
|
458
|
+
it('상자 밑면이 배치가 말하는 높이에 온다', () => {
|
|
459
|
+
/* `figure-preview.ts` 의 `bottomOf` 와 같은 계산 — 규칙이 하나임을 여기에 못 박는다. */
|
|
460
|
+
const bottomOf = (placement: FigureSource['placement'], height: number, ceiling: number) =>
|
|
461
|
+
placement === 'ceiling' ? ceiling - height : placement === 'center' ? (ceiling - height) / 2 : 0
|
|
462
|
+
|
|
463
|
+
const CEILING = 4000
|
|
464
|
+
|
|
465
|
+
/* 바닥 기반이면 키가 달라도 **밑면**이 한 줄이다. */
|
|
466
|
+
assert.deepEqual([3000, 1000, 500].map(h => bottomOf('floor', h, CEILING)), [0, 0, 0])
|
|
467
|
+
|
|
468
|
+
/* 천정 기반이면 **윗면**이 한 줄이다 — 밑면은 저마다 다르다. */
|
|
469
|
+
assert.deepEqual(
|
|
470
|
+
[3000, 1000, 500].map(h => bottomOf('ceiling', h, CEILING) + h),
|
|
471
|
+
[CEILING, CEILING, CEILING]
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
/* 중심 기반이면 **중심**이 한 줄이다. */
|
|
475
|
+
assert.deepEqual(
|
|
476
|
+
[3000, 1000, 500].map(h => bottomOf('center', h, CEILING) + h / 2),
|
|
477
|
+
[CEILING / 2, CEILING / 2, CEILING / 2]
|
|
478
|
+
)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('밑면을 씬이 받는 zPos 로 옮긴다 — 좌표 모드마다 다른 수다', () => {
|
|
482
|
+
const zPosOf = (bottom: number, height: number, reference: string) =>
|
|
483
|
+
reference === 'space' ? bottom + height / 2 : reference === 'inverted' ? bottom + height : bottom
|
|
484
|
+
|
|
485
|
+
/*
|
|
486
|
+
씬은 부품 중심을 `zPos + geometricOffsetY` 에 놓고 그 오프셋이 모드마다 다르다
|
|
487
|
+
(`real-object.ts`). 어느 모드에서든 중심이 같은 자리에 와야 같은 밑면을 말한 것이다.
|
|
488
|
+
*/
|
|
489
|
+
const offset = { floor: (h: number) => h / 2, space: () => 0, inverted: (h: number) => -h / 2 }
|
|
490
|
+
const BOTTOM = 3700
|
|
491
|
+
const HEIGHT = 300
|
|
492
|
+
|
|
493
|
+
for (const reference of ['floor', 'space', 'inverted'] as const) {
|
|
494
|
+
const centre = zPosOf(BOTTOM, HEIGHT, reference) + offset[reference](HEIGHT)
|
|
495
|
+
assert.equal(centre, BOTTOM + HEIGHT / 2, `${reference}: 같은 밑면이 다른 자리에 섰다`)
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('미리보기가 좌표 모드를 보기 설정에서 받는다 — 도형에서가 아니라', () => {
|
|
500
|
+
assert.match(preview, /placement: view\.placement/, '보기 설정의 좌표 모드를 씬에 실어야 한다')
|
|
501
|
+
assert.doesNotMatch(preview, /placement: (source|toScenePlacement)/, '도형의 배치를 씬 모드로 쓰면 안 된다')
|
|
502
|
+
assert.match(preview, /zPos: Math\.round\(zPosOf\(/, '인스턴스 높이를 배치에서 뽑아야 한다')
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('미리보기가 보기 설정의 조명·환경을 따른다', () => {
|
|
506
|
+
assert.match(preview, /\.\.\.sceneLook\(view\)/, '하드코딩하면 편집 캔버스와 두 화면이 갈린다')
|
|
507
|
+
assert.doesNotMatch(preview, /environment: 'studio'/, '환경이 하드코딩으로 남아 있다')
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe('보기 설정이 편집 캔버스로 새지 않는다', () => {
|
|
512
|
+
const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
|
|
513
|
+
|
|
514
|
+
it('sceneLook 이 좌표 모드와 천장 높이를 뺀다', () => {
|
|
515
|
+
const view = read('../client/modeller/figure-view.ts')
|
|
516
|
+
assert.match(view, /const \{ placement, ceilingHeight, \.\.\.look \} = view/)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('편집 캔버스가 rest spread 로 보기 설정을 넘기지 않는다', () => {
|
|
520
|
+
/*
|
|
521
|
+
한동안 `const { grid, ..., ...light } = view` 였다. 그러면 설정을 하나 더할 때마다
|
|
522
|
+
그것이 두 화면에 **말없이** 실린다 — 좌표 모드가 바로 그 길로 들어갈 뻔했다.
|
|
523
|
+
*/
|
|
524
|
+
const canvas = read('../client/modeller/figure-canvas.ts')
|
|
525
|
+
const fn = canvas.slice(canvas.indexOf('private sceneView'), canvas.indexOf('private sceneView') + 200)
|
|
526
|
+
|
|
527
|
+
assert.match(fn, /sceneLook\(/)
|
|
528
|
+
assert.doesNotMatch(fn, /\.\.\.light/, '나머지를 통째로 넘기면 새 설정이 조용히 실린다')
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
/*
|
|
533
|
+
* ── 막는 것과 알리는 것을 화면이 갈라야 한다 ──────────────────────────────────
|
|
534
|
+
*
|
|
535
|
+
* 판정 패널은 위반을 전부 같은 `info` 로 그렸다. 기준 상자 위반 둘이 발행을 막게 된 뒤에도
|
|
536
|
+
* 그대로라, 저작자는 다섯 줄의 알림을 보고 **발행을 눌러 보고 나서야** 막힌 것을 안다.
|
|
537
|
+
*
|
|
538
|
+
* 등급은 형식이 안다(`blocksRelease`). 화면이 제 목록을 들면 코드가 하나 늘 때 조용히
|
|
539
|
+
* 「알림」 쪽으로 떨어진다 — 안전하지 않은 쪽으로 기우는 기본값이다.
|
|
540
|
+
*/
|
|
541
|
+
describe('판정 패널이 발행을 막는 것을 갈라 보인다', () => {
|
|
542
|
+
const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
|
|
543
|
+
const report = read('../client/modeller/figure-report.ts')
|
|
544
|
+
|
|
545
|
+
it('등급을 형식에서 받는다 — 제 목록을 들지 않는다', () => {
|
|
546
|
+
assert.match(report, /blocksRelease/, '형식이 어느 위반이 막는지 안다')
|
|
547
|
+
assert.doesNotMatch(
|
|
548
|
+
report,
|
|
549
|
+
/'part-outside-base'|'parts-off-placement-face'/,
|
|
550
|
+
'화면이 코드 이름을 적으면 형식과 두 벌이 된다'
|
|
551
|
+
)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('막는 것은 다른 아이콘으로 그린다', () => {
|
|
555
|
+
assert.match(report, /v\.blocking \? 'block' : 'info'/, '같은 아이콘이면 갈린 뜻이 화면에 안 나온다')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('얼마나 넘쳤는지 보여 준다', () => {
|
|
559
|
+
/* 「상자를 벗어난다」만으로는 2mm 인지 250mm 인지 모르고, 그 둘은 고치는 품이 다르다. */
|
|
560
|
+
assert.match(report, /v\.actual/, '넘친 양을 버리면 저작자가 무엇부터 고칠지 못 고른다')
|
|
561
|
+
})
|
|
562
|
+
})
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 여럿이면 마지막이 이기고 아무 경고도 없다. 그래서 내 파일을 읽어도 화면에 뜨는 값을 알 수
|
|
7
7
|
* 없다 — 내가 적은 값이 아니라 이긴 값이 뜬다.
|
|
8
8
|
*
|
|
9
|
-
* 실제로 그랬다. figure 앱의 발행 단추가 「배포」로 떴다(
|
|
9
|
+
* 실제로 그랬다. figure 앱의 발행 단추가 「배포」로 떴다(draft-ui 의 `button.release` 가 이겼다).
|
|
10
10
|
* 그때 figure 의 266개 키 중 아홉이 **다른 뜻으로** 겹쳐 있었다 — 모델러가 자기 부품을 board 의
|
|
11
11
|
* 낱말인 「품목」이라 부르고 있었고 아무것도 그것을 말해 주지 않았다.
|
|
12
12
|
*
|