@things-factory/figure-service 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.
- package/README.md +34 -0
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +6 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/migrations/index.d.ts +1 -0
- package/dist-server/migrations/index.js +12 -0
- package/dist-server/migrations/index.js.map +1 -0
- package/dist-server/service/figure/figure-mutation.d.ts +19 -0
- package/dist-server/service/figure/figure-mutation.js +171 -0
- package/dist-server/service/figure/figure-mutation.js.map +1 -0
- package/dist-server/service/figure/figure-propose-type.d.ts +43 -0
- package/dist-server/service/figure/figure-propose-type.js +89 -0
- package/dist-server/service/figure/figure-propose-type.js.map +1 -0
- package/dist-server/service/figure/figure-propose.d.ts +67 -0
- package/dist-server/service/figure/figure-propose.js +213 -0
- package/dist-server/service/figure/figure-propose.js.map +1 -0
- package/dist-server/service/figure/figure-query.d.ts +16 -0
- package/dist-server/service/figure/figure-query.js +77 -0
- package/dist-server/service/figure/figure-query.js.map +1 -0
- package/dist-server/service/figure/figure-source.d.ts +45 -0
- package/dist-server/service/figure/figure-source.js +53 -0
- package/dist-server/service/figure/figure-source.js.map +1 -0
- package/dist-server/service/figure/figure-type.d.ts +57 -0
- package/dist-server/service/figure/figure-type.js +149 -0
- package/dist-server/service/figure/figure-type.js.map +1 -0
- package/dist-server/service/figure/figure.d.ts +71 -0
- package/dist-server/service/figure/figure.js +160 -0
- package/dist-server/service/figure/figure.js.map +1 -0
- package/dist-server/service/figure/index.d.ts +5 -0
- package/dist-server/service/figure/index.js +9 -0
- package/dist-server/service/figure/index.js.map +1 -0
- package/dist-server/service/index.d.ts +5 -0
- package/dist-server/service/index.js +13 -0
- package/dist-server/service/index.js.map +1 -0
- package/package.json +33 -0
- package/server/index.ts +2 -0
- package/server/migrations/index.ts +9 -0
- package/server/service/figure/figure-mutation.ts +163 -0
- package/server/service/figure/figure-propose-type.ts +74 -0
- package/server/service/figure/figure-propose.test.ts +164 -0
- package/server/service/figure/figure-propose.ts +271 -0
- package/server/service/figure/figure-query.ts +60 -0
- package/server/service/figure/figure-source.test.ts +129 -0
- package/server/service/figure/figure-source.ts +87 -0
- package/server/service/figure/figure-type.ts +108 -0
- package/server/service/figure/figure.ts +168 -0
- package/server/service/figure/index.ts +6 -0
- package/server/service/index.ts +10 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { In } from 'typeorm'
|
|
2
|
+
import { Args, Arg, Ctx, Directive, Query, Resolver } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain, getQueryBuilderFromListParams, getRepository, ListParam } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
import { Figure } from './figure.js'
|
|
7
|
+
import { FigureList } from './figure-type.js'
|
|
8
|
+
|
|
9
|
+
@Resolver(Figure)
|
|
10
|
+
export class FigureQuery {
|
|
11
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
12
|
+
@Query(returns => Figure, { nullable: true, description: 'Finds a single figure by its ID.' })
|
|
13
|
+
async figure(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<Figure> {
|
|
14
|
+
const { domain } = context.state
|
|
15
|
+
|
|
16
|
+
// 부모 도메인의 Figure 도 보인다 — Board 와 같은 범위 규칙이다.
|
|
17
|
+
return await getRepository(Figure).findOne({
|
|
18
|
+
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
23
|
+
@Query(returns => FigureList, { description: 'Lists figures. Sortable by score, triangles, name, updatedAt.' })
|
|
24
|
+
async figures(@Args() params: ListParam, @Ctx() context: ResolverContext): Promise<FigureList> {
|
|
25
|
+
const { domain } = context.state
|
|
26
|
+
|
|
27
|
+
const queryBuilder = getQueryBuilderFromListParams({
|
|
28
|
+
repository: getRepository(Figure),
|
|
29
|
+
params,
|
|
30
|
+
domain,
|
|
31
|
+
alias: 'figure',
|
|
32
|
+
searchables: ['type', 'name', 'description', 'category']
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const [items, total] = await queryBuilder.getManyAndCount()
|
|
36
|
+
|
|
37
|
+
return { items, total }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 등록된 타입 이름 목록.
|
|
42
|
+
*
|
|
43
|
+
* 저작 화면이 **새 타입 이름이 이미 쓰이는지** 물어보는 데 쓴다. `type` 은 저장되는
|
|
44
|
+
* 식별자라 만든 뒤에 고칠 수 없으므로, 만들기 전에 알려 줘야 한다.
|
|
45
|
+
*
|
|
46
|
+
* `state` 로 걸러지지 않는다 — `draft` 인 Figure 의 이름도 이미 점유된 것이다.
|
|
47
|
+
*/
|
|
48
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
49
|
+
@Query(returns => [String], { description: 'All figure type names in scope — used to check name collisions before create.' })
|
|
50
|
+
async figureTypeNames(@Ctx() context: ResolverContext): Promise<string[]> {
|
|
51
|
+
const { domain } = context.state
|
|
52
|
+
|
|
53
|
+
const rows = await getRepository(Figure).find({
|
|
54
|
+
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) } },
|
|
55
|
+
select: { type: true }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return rows.map(r => r.type).filter(Boolean)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figure-source — 정본이 들어오는 문에서 무엇을 막고 무엇을 알리나.
|
|
3
|
+
*
|
|
4
|
+
* 이 파일이 지키는 것은 하나다. **서버가 형식을 어긴 정본을 저장하지 않는다.**
|
|
5
|
+
* 저작 화면이 검증하니 서버는 믿어도 된다는 말이 안 되는 이유는, 저작 화면을 거치지
|
|
6
|
+
* 않는 길(가져오기·API·AI 어시스턴트)이 있기 때문이다.
|
|
7
|
+
*
|
|
8
|
+
* 그리고 파생값(`score`·`triangles`·`groups`)을 **클라이언트에서 받지 않는다**는 것도
|
|
9
|
+
* 여기서 지킨다. 받으면 목록 화면이 실물과 다른 것을 보여 준다.
|
|
10
|
+
*/
|
|
11
|
+
import { FigureSourceError, parseSource } from './figure-source'
|
|
12
|
+
|
|
13
|
+
/** 온전한 부품 하나. 시험할 필드만 덮어쓴다. */
|
|
14
|
+
function part(over: Record<string, unknown> = {}) {
|
|
15
|
+
return {
|
|
16
|
+
name: 'body',
|
|
17
|
+
primitive: 'cube',
|
|
18
|
+
transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
|
|
19
|
+
material: { token: 'palette.primary' },
|
|
20
|
+
...over
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 온전한 정본 하나. */
|
|
25
|
+
function source(over: Record<string, unknown> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
type: 'MIXER',
|
|
28
|
+
base: { x: 300, y: 800, z: 300 },
|
|
29
|
+
detailLevel: 'M',
|
|
30
|
+
parts: [part()],
|
|
31
|
+
...over
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const json = (v: unknown) => JSON.stringify(v)
|
|
36
|
+
|
|
37
|
+
describe('figure-source — 들어오는 문', () => {
|
|
38
|
+
describe('막는다', () => {
|
|
39
|
+
it('JSON 이 아니면 던진다 — 무엇이 잘못됐는지 말한다', () => {
|
|
40
|
+
let thrown: FigureSourceError | undefined
|
|
41
|
+
try {
|
|
42
|
+
parseSource('{ 이건 JSON 이 아니다')
|
|
43
|
+
} catch (e) {
|
|
44
|
+
thrown = e as FigureSourceError
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
48
|
+
expect(thrown!.findings[0].code).toBe('source-not-json')
|
|
49
|
+
// 조용히 빈 값으로 넘기지 않는다
|
|
50
|
+
expect(thrown!.findings[0].message).toContain('JSON')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('객체가 아니면 던진다', () => {
|
|
54
|
+
for (const bad of ['null', '42', '"x"', '[]']) {
|
|
55
|
+
expect(() => parseSource(bad)).toThrow(FigureSourceError)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('형식을 어기면 던지고, figure-model 의 코드를 그대로 전한다', () => {
|
|
60
|
+
let thrown: FigureSourceError | undefined
|
|
61
|
+
try {
|
|
62
|
+
// 부품이 없는 정본
|
|
63
|
+
parseSource(json(source({ parts: [] })))
|
|
64
|
+
} catch (e) {
|
|
65
|
+
thrown = e as FigureSourceError
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
69
|
+
expect(thrown!.findings.length).toBeGreaterThan(0)
|
|
70
|
+
// 코드를 서버가 다시 짓지 않는다 — figure-model 의 것을 그대로 쓴다
|
|
71
|
+
for (const f of thrown!.findings) {
|
|
72
|
+
expect(typeof f.code).toBe('string')
|
|
73
|
+
expect(f.code.length).toBeGreaterThan(0)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('어디서 났는지(path)를 함께 전한다 — 화면이 그 자리를 가리킬 수 있게', () => {
|
|
78
|
+
let thrown: FigureSourceError | undefined
|
|
79
|
+
try {
|
|
80
|
+
parseSource(json(source({ parts: [part({ transform: { position: { x: 0, y: 0, z: 0 }, size: { x: -1, y: 1, z: 1 } } })] })))
|
|
81
|
+
} catch (e) {
|
|
82
|
+
thrown = e as FigureSourceError
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(thrown).toBeInstanceOf(FigureSourceError)
|
|
86
|
+
expect(thrown!.findings.some(f => !!f.at)).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('통과시킨다', () => {
|
|
91
|
+
it('온전한 정본은 그대로 통과한다', () => {
|
|
92
|
+
const result = parseSource(json(source()))
|
|
93
|
+
expect(result.violations).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('정본을 다시 직렬화하지 않는다 — 들어온 문자열을 그대로 저장한다', () => {
|
|
97
|
+
// 다시 만들면 키 순서·공백이 바뀌어 「고치지 않았는데 바뀌었다」가 된다
|
|
98
|
+
const raw = json(source())
|
|
99
|
+
expect(parseSource(raw).source).toBe(raw)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('파생값은 서버가 계산한다', () => {
|
|
104
|
+
it('점수·삼각형 수·묶음 수가 채워진다', () => {
|
|
105
|
+
const { derived } = parseSource(json(source()))
|
|
106
|
+
|
|
107
|
+
expect(typeof derived.score).toBe('number')
|
|
108
|
+
expect(derived.score).toBeGreaterThanOrEqual(0)
|
|
109
|
+
expect(derived.score).toBeLessThanOrEqual(100)
|
|
110
|
+
expect(derived.triangles).toBeGreaterThan(0)
|
|
111
|
+
expect(derived.groups).toBeGreaterThan(0)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('클라이언트가 보낸 값은 쓰이지 않는다 — 정본만 본다', () => {
|
|
115
|
+
// 정본 안에 거짓 파생값을 심어도 셈은 부품에서 나온다
|
|
116
|
+
const lying = json(source({ score: 100, triangles: 1, groups: 1 } as Record<string, unknown>))
|
|
117
|
+
const honest = json(source())
|
|
118
|
+
|
|
119
|
+
expect(parseSource(lying).derived).toEqual(parseSource(honest).derived)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('부품이 늘면 삼각형 수도 는다 — 셈이 정본을 실제로 읽는다', () => {
|
|
123
|
+
const one = parseSource(json(source())).derived.triangles
|
|
124
|
+
const two = parseSource(json(source({ parts: [part(), part({ name: 'lid' })] }))).derived.triangles
|
|
125
|
+
|
|
126
|
+
expect(two).toBeGreaterThan(one)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { compile, costOf, scoreOf, validate } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
import { FigureFinding } from './figure-type.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 정본을 받아 **검증하고 파생값을 계산한다.**
|
|
7
|
+
*
|
|
8
|
+
* ## 왜 서버가 검증하나
|
|
9
|
+
*
|
|
10
|
+
* 저작 화면을 못 믿어서가 아니다. **저작 화면을 거치지 않는 길이 있다** — 가져오기,
|
|
11
|
+
* API 직접 호출, AI 어시스턴트. 그 길로 들어온 정본이 형식을 어기면 보드에서 조용히
|
|
12
|
+
* 안 그려진다. 들어오는 문에서 막는다.
|
|
13
|
+
*
|
|
14
|
+
* ## 막는 것과 알리는 것
|
|
15
|
+
*
|
|
16
|
+
* `figure-model` 이 두 단계로 나눠 놓았고, 서버가 그 경계를 다시 정하지 않는다.
|
|
17
|
+
*
|
|
18
|
+
* errors 형식을 어긴 것. **거절한다**
|
|
19
|
+
* violations 한도를 넘은 것. 거절하지 않고 **함께 돌려준다**
|
|
20
|
+
*
|
|
21
|
+
* ## 파생값은 서버가 계산한다
|
|
22
|
+
*
|
|
23
|
+
* `score`·`triangles`·`groups` 를 클라이언트에서 받지 않는다. 받으면 거짓이 들어오고
|
|
24
|
+
* 목록 화면이 실물과 다른 것을 보여 준다. 정본에서 계산한다.
|
|
25
|
+
*/
|
|
26
|
+
export interface ParsedSource {
|
|
27
|
+
/** 저장할 정본 JSON 문자열. 들어온 것을 그대로 쓴다 — 다시 직렬화하지 않는다. */
|
|
28
|
+
source: string
|
|
29
|
+
/** 목록 화면이 정렬·필터에 쓰는 파생값. */
|
|
30
|
+
derived: { score: number; triangles: number; groups: number }
|
|
31
|
+
/** 막지 않는 발견. 호출한 쪽이 응답에 실어 보낸다. */
|
|
32
|
+
violations: FigureFinding[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 정본이 형식을 어겼다. 저장하지 않는다. */
|
|
36
|
+
export class FigureSourceError extends Error {
|
|
37
|
+
readonly findings: FigureFinding[]
|
|
38
|
+
|
|
39
|
+
constructor(findings: FigureFinding[]) {
|
|
40
|
+
super(`정본이 형식을 어겼다 — ${findings.length}건: ${findings.map(f => f.code).join(', ')}`)
|
|
41
|
+
this.name = 'FigureSourceError'
|
|
42
|
+
this.findings = findings
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 정본 JSON 문자열을 검증하고 파생값을 계산한다.
|
|
48
|
+
*
|
|
49
|
+
* @throws {FigureSourceError} JSON 이 아니거나 형식을 어긴 경우
|
|
50
|
+
*/
|
|
51
|
+
export function parseSource(sourceJson: string): ParsedSource {
|
|
52
|
+
let parsed: unknown
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(sourceJson)
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new FigureSourceError([
|
|
57
|
+
{ code: 'source-not-json', message: `정본이 JSON 이 아니다: ${(e as Error).message}`, at: 'source' }
|
|
58
|
+
])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = validate(parsed)
|
|
62
|
+
if (result.errors.length > 0) {
|
|
63
|
+
throw new FigureSourceError(
|
|
64
|
+
result.errors.map(e => ({ code: e.code, message: e.message, at: e.path }))
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 여기까지 왔으면 형식은 맞다. 청사진을 만들어 셈에 쓴다.
|
|
69
|
+
// 청사진 자체는 **저장하지 않는다** — 파생 캐시이므로 소비하는 쪽이 다시 만든다.
|
|
70
|
+
const blueprint = compile(parsed as any)
|
|
71
|
+
const cost = costOf(blueprint, 1)
|
|
72
|
+
const score = scoreOf(blueprint)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
source: sourceJson,
|
|
76
|
+
derived: {
|
|
77
|
+
score: score.score,
|
|
78
|
+
triangles: cost.triangles,
|
|
79
|
+
groups: cost.groups
|
|
80
|
+
},
|
|
81
|
+
violations: result.violations.map(v => ({
|
|
82
|
+
code: v.code,
|
|
83
|
+
message: v.message,
|
|
84
|
+
at: undefined
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Field, InputType, Int, ObjectType } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { Figure } from './figure.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 새 Figure.
|
|
7
|
+
*
|
|
8
|
+
* `score`·`triangles`·`groups` 는 **받지 않는다.** 서버가 `source` 에서 계산한다 —
|
|
9
|
+
* 받으면 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.
|
|
10
|
+
*/
|
|
11
|
+
@InputType({ description: 'Input for creating a new figure.' })
|
|
12
|
+
export class NewFigure {
|
|
13
|
+
@Field({ description: 'Component type name. Immutable once created, unique per domain.' })
|
|
14
|
+
type: string
|
|
15
|
+
|
|
16
|
+
@Field({ description: 'Display name shown to authors.' })
|
|
17
|
+
name: string
|
|
18
|
+
|
|
19
|
+
@Field({ nullable: true, description: 'A detailed description for the figure.' })
|
|
20
|
+
description?: string
|
|
21
|
+
|
|
22
|
+
@Field({ nullable: true, description: 'Classification code used to group figures in the palette.' })
|
|
23
|
+
category?: string
|
|
24
|
+
|
|
25
|
+
@Field({ description: 'The canonical FigureSource JSON.' })
|
|
26
|
+
source: string
|
|
27
|
+
|
|
28
|
+
@Field({ nullable: true, description: 'JSON definition of the master-data fields an instance carries.' })
|
|
29
|
+
properties?: string
|
|
30
|
+
|
|
31
|
+
@Field({ nullable: true, description: "Lifecycle state — defaults to 'draft'." })
|
|
32
|
+
state?: 'draft' | 'released'
|
|
33
|
+
|
|
34
|
+
@Field({ nullable: true, description: 'A base64 encoded thumbnail image.' })
|
|
35
|
+
thumbnail?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 수정.
|
|
40
|
+
*
|
|
41
|
+
* `type` 이 없다. **저장되는 식별자라 고칠 수 없다** — 바꾸면 이미 배치된 보드가
|
|
42
|
+
* 타입을 못 찾아 오류 없이 빈 화면이 된다. 고칠 수 있는 이름은 `name` 뿐이다.
|
|
43
|
+
*/
|
|
44
|
+
@InputType({ description: 'Input for updating an existing figure. The `type` field is absent by design — it is a stored identifier.' })
|
|
45
|
+
export class FigurePatch {
|
|
46
|
+
@Field({ nullable: true, description: 'The new display name.' })
|
|
47
|
+
name?: string
|
|
48
|
+
|
|
49
|
+
@Field({ nullable: true, description: 'The new description.' })
|
|
50
|
+
description?: string
|
|
51
|
+
|
|
52
|
+
@Field({ nullable: true, description: 'The new classification code.' })
|
|
53
|
+
category?: string
|
|
54
|
+
|
|
55
|
+
@Field({ nullable: true, description: 'The new FigureSource JSON.' })
|
|
56
|
+
source?: string
|
|
57
|
+
|
|
58
|
+
@Field({ nullable: true, description: 'The new master-data field definition.' })
|
|
59
|
+
properties?: string
|
|
60
|
+
|
|
61
|
+
@Field({ nullable: true, description: 'The new lifecycle state.' })
|
|
62
|
+
state?: 'draft' | 'released'
|
|
63
|
+
|
|
64
|
+
@Field({ nullable: true, description: 'A base64 encoded thumbnail image.' })
|
|
65
|
+
thumbnail?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@ObjectType({ description: 'A paginated list of figures.' })
|
|
69
|
+
export class FigureList {
|
|
70
|
+
@Field(type => [Figure], { description: 'The figures on this page.' })
|
|
71
|
+
items: Figure[]
|
|
72
|
+
|
|
73
|
+
@Field(type => Int, { description: 'Total number of figures matching the query.' })
|
|
74
|
+
total: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 검증 결과 한 줄.
|
|
79
|
+
*
|
|
80
|
+
* 정본 형식의 검증은 `@hatiolab/figure-model` 이 두 단계로 나눈다 — `errors` 는
|
|
81
|
+
* 막고, `violations` 는 알린다. 그 경계를 서버가 다시 정하지 않는다.
|
|
82
|
+
*/
|
|
83
|
+
@ObjectType({ description: 'One validation finding from figure-model.' })
|
|
84
|
+
export class FigureFinding {
|
|
85
|
+
@Field({ description: 'Stable code from figure-model (ERROR_CODES / VIOLATION_CODES).' })
|
|
86
|
+
code: string
|
|
87
|
+
|
|
88
|
+
@Field({ description: 'Human-readable message.' })
|
|
89
|
+
message: string
|
|
90
|
+
|
|
91
|
+
@Field({ nullable: true, description: 'Path within the source that the finding points at.' })
|
|
92
|
+
at?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 저장 결과.
|
|
97
|
+
*
|
|
98
|
+
* `violations` 가 있어도 저장은 된다 — 막는 것과 알리는 것을 가른다. 화면이
|
|
99
|
+
* 이것을 그대로 보여 줘야 한다. 조용히 버리면 저작자가 모른다.
|
|
100
|
+
*/
|
|
101
|
+
@ObjectType({ description: 'Result of saving a figure, carrying non-blocking violations.' })
|
|
102
|
+
export class FigureSaveResult {
|
|
103
|
+
@Field(type => Figure, { description: 'The saved figure.' })
|
|
104
|
+
figure: Figure
|
|
105
|
+
|
|
106
|
+
@Field(type => [FigureFinding], { description: 'Non-blocking findings. The save succeeded despite these.' })
|
|
107
|
+
violations: FigureFinding[]
|
|
108
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Column,
|
|
3
|
+
CreateDateColumn,
|
|
4
|
+
DeleteDateColumn,
|
|
5
|
+
Entity,
|
|
6
|
+
Index,
|
|
7
|
+
ManyToOne,
|
|
8
|
+
PrimaryGeneratedColumn,
|
|
9
|
+
RelationId,
|
|
10
|
+
UpdateDateColumn
|
|
11
|
+
} from 'typeorm'
|
|
12
|
+
import { Field, Float, ID, Int, ObjectType } from 'type-graphql'
|
|
13
|
+
|
|
14
|
+
import { User } from '@things-factory/auth-base'
|
|
15
|
+
import { Domain, longTextColumn } from '@things-factory/shell'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Figure — 저작된 3D 컴포넌트 한 종류.
|
|
19
|
+
*
|
|
20
|
+
* `Board` 를 준용한다. 다른 점만 주석으로 적는다.
|
|
21
|
+
*
|
|
22
|
+
* ## 무엇을 담나 — `FigureSource` 다, 씬 모델이 아니다
|
|
23
|
+
*
|
|
24
|
+
* 정본 형식은 `@hatiolab/figure-model` 이 정한다(`FigureSource`). 씬 컴포넌트 트리를
|
|
25
|
+
* 담지 않는다. 담으면 저작도구가 씬 편집기가 되고, 부품 이름·상태 슬롯·애니메이션
|
|
26
|
+
* 자리가 형식에 없어 따로 관리해야 한다.
|
|
27
|
+
*
|
|
28
|
+
* 청사진(`FigureBlueprint`)은 **저장하지 않는다.** 정본에서 파생되는 캐시이므로
|
|
29
|
+
* 소비하는 쪽이 `compile()` 로 만든다. 저장하면 정본과 갈린다.
|
|
30
|
+
*
|
|
31
|
+
* 그래서 `baseWidth`·`anchor`·`statusSlots`·`parts`·`animations` 같은 것을 컬럼으로
|
|
32
|
+
* 두지 않는다 — 전부 `source` 안에 이미 있는 자리다. 두 벌이면 갈린다.
|
|
33
|
+
*
|
|
34
|
+
* 설계: `docs/design/figure-modules.md` §2
|
|
35
|
+
*/
|
|
36
|
+
@Entity()
|
|
37
|
+
@Index('ix_figure_1', (figure: Figure) => [figure.domain, figure.type], {
|
|
38
|
+
unique: true,
|
|
39
|
+
where: '"deleted_at" IS NULL'
|
|
40
|
+
})
|
|
41
|
+
@Index('ix_figure_2', (figure: Figure) => [figure.domain, figure.category])
|
|
42
|
+
@ObjectType({ description: 'An authored 3D component type — the canonical FigureSource plus its metadata.' })
|
|
43
|
+
export class Figure {
|
|
44
|
+
@PrimaryGeneratedColumn('uuid')
|
|
45
|
+
@Field(type => ID, { nullable: true, description: 'Unique identifier for the figure.' })
|
|
46
|
+
readonly id?: string
|
|
47
|
+
|
|
48
|
+
@Column({ nullable: true, default: 1 })
|
|
49
|
+
@Field(type => Int, { nullable: true, description: 'Revision counter, incremented on each save.' })
|
|
50
|
+
version?: number = 1
|
|
51
|
+
|
|
52
|
+
@ManyToOne(type => Domain)
|
|
53
|
+
@Field(type => Domain, { nullable: true, description: 'The domain this figure belongs to.' })
|
|
54
|
+
domain?: Domain
|
|
55
|
+
|
|
56
|
+
@RelationId((figure: Figure) => figure.domain)
|
|
57
|
+
domainId?: string
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 컴포넌트 타입 이름. **저장되는 식별자다.**
|
|
61
|
+
*
|
|
62
|
+
* 배치된 보드가 이 이름으로 타입을 찾는다. 바꾸면 그 보드는 타입을 못 찾아
|
|
63
|
+
* **오류 없이 빈 화면**이 된다. 그래서 만들 때 정하고 잠근다 — 고칠 수 있는 것은
|
|
64
|
+
* 표시 이름(`name`)뿐이다. 수정 리졸버가 이 필드를 받지 않는다.
|
|
65
|
+
*
|
|
66
|
+
* `Board.type`(main/sub/popup)과 뜻이 전혀 다르다. 혼동하지 않는다.
|
|
67
|
+
*/
|
|
68
|
+
@Column()
|
|
69
|
+
@Field({ description: 'Component type name. A stored identifier — unique per domain and immutable once created.' })
|
|
70
|
+
type?: string
|
|
71
|
+
|
|
72
|
+
@Column()
|
|
73
|
+
@Field({ description: 'Display name shown to authors. Safe to rename.' })
|
|
74
|
+
name?: string
|
|
75
|
+
|
|
76
|
+
@Column({ nullable: true })
|
|
77
|
+
@Field({ nullable: true, description: 'A detailed description for the figure.' })
|
|
78
|
+
description?: string
|
|
79
|
+
|
|
80
|
+
/** 분류 코드. 팔레트에서 묶어 보이는 기준. */
|
|
81
|
+
@Column({ nullable: true })
|
|
82
|
+
@Field({ nullable: true, description: 'Classification code used to group figures in the palette.' })
|
|
83
|
+
category?: string
|
|
84
|
+
|
|
85
|
+
@Column({ nullable: true, default: 'draft' })
|
|
86
|
+
@Field({ nullable: true, description: "Lifecycle state — 'draft' figures do not appear in the board palette." })
|
|
87
|
+
state?: 'draft' | 'released'
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `FigureSource` JSON.
|
|
91
|
+
*
|
|
92
|
+
* `Board.model` 자리이지만 이름을 바꿨다 — 「씬 모델이 아니다」를 필드 이름이
|
|
93
|
+
* 드러내야 한다.
|
|
94
|
+
*/
|
|
95
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
96
|
+
@Field({ nullable: true, description: 'The canonical FigureSource JSON authored by the modeller.' })
|
|
97
|
+
source?: string
|
|
98
|
+
|
|
99
|
+
// ── 파생값 — 저장하는 이유가 하나뿐이다 ──────────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// 셋 다 `source` 에서 계산된다. 그런데도 컬럼으로 두는 이유는 **목록 화면이
|
|
102
|
+
// 정렬·필터를 해야** 하기 때문이다. JSON 을 파싱해 정렬할 수는 없다.
|
|
103
|
+
//
|
|
104
|
+
// 저장할 때 `figure-model` 로 계산해 채운다. **클라이언트가 준 값을 받지 않는다** —
|
|
105
|
+
// 받으면 거짓이 들어오고, 목록이 실물과 다른 것을 보여 준다.
|
|
106
|
+
|
|
107
|
+
/** 재활용 점수 0~100. `scoreOf()` 가 계산한다. */
|
|
108
|
+
@Column({ type: 'float', nullable: true })
|
|
109
|
+
@Field(type => Float, { nullable: true, description: 'Reuse score 0-100, computed from source on save.' })
|
|
110
|
+
score?: number
|
|
111
|
+
|
|
112
|
+
/** 삼각형 수. `costOf()` 가 계산한다. */
|
|
113
|
+
@Column({ nullable: true })
|
|
114
|
+
@Field(type => Int, { nullable: true, description: 'Triangle count, computed from source on save.' })
|
|
115
|
+
triangles?: number
|
|
116
|
+
|
|
117
|
+
/** 재질 묶음 수 — 인스턴스당 draw call 의 근거. */
|
|
118
|
+
@Column({ nullable: true })
|
|
119
|
+
@Field(type => Int, { nullable: true, description: 'Material group count — the basis of per-instance draw calls.' })
|
|
120
|
+
groups?: number
|
|
121
|
+
|
|
122
|
+
/** 기준정보 항목 정의. Figure 를 배치할 때 인스턴스가 채우는 자리. */
|
|
123
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
124
|
+
@Field({ nullable: true, description: 'JSON definition of the master-data fields an instance of this figure carries.' })
|
|
125
|
+
properties?: string
|
|
126
|
+
|
|
127
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
128
|
+
@Field({ nullable: true, description: 'A base64 encoded thumbnail image of the figure.' })
|
|
129
|
+
thumbnail?: string
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 썸네일이 **썸네일로서** 마지막으로 갱신된 시각. `updatedAt` 과는 별개 도메인이다 —
|
|
133
|
+
* 이름·설명만 고쳤을 때는 갱신되지 않는다. `Board` 와 같은 방식.
|
|
134
|
+
*/
|
|
135
|
+
@Column({ nullable: true })
|
|
136
|
+
@Field({ nullable: true, description: 'Timestamp of the last thumbnail generation. Separate lifecycle from updatedAt.' })
|
|
137
|
+
thumbnailUpdatedAt?: Date
|
|
138
|
+
|
|
139
|
+
@Column({ type: 'float', nullable: true, default: 0 })
|
|
140
|
+
@Field(type => Float, { nullable: true, description: 'Sort order within a listing.' })
|
|
141
|
+
sortOrder?: number
|
|
142
|
+
|
|
143
|
+
@CreateDateColumn()
|
|
144
|
+
@Field({ nullable: true, description: 'Creation timestamp.' })
|
|
145
|
+
createdAt?: Date
|
|
146
|
+
|
|
147
|
+
@UpdateDateColumn()
|
|
148
|
+
@Field({ nullable: true, description: 'Last update timestamp.' })
|
|
149
|
+
updatedAt?: Date
|
|
150
|
+
|
|
151
|
+
@DeleteDateColumn()
|
|
152
|
+
@Field({ nullable: true, description: 'Soft-delete timestamp.' })
|
|
153
|
+
deletedAt?: Date
|
|
154
|
+
|
|
155
|
+
@ManyToOne(type => User, { nullable: true })
|
|
156
|
+
@Field(type => User, { nullable: true, description: 'The user who created this figure.' })
|
|
157
|
+
creator?: User
|
|
158
|
+
|
|
159
|
+
@RelationId((figure: Figure) => figure.creator)
|
|
160
|
+
creatorId?: string
|
|
161
|
+
|
|
162
|
+
@ManyToOne(type => User, { nullable: true })
|
|
163
|
+
@Field(type => User, { nullable: true, description: 'The user who last updated this figure.' })
|
|
164
|
+
updater?: User
|
|
165
|
+
|
|
166
|
+
@RelationId((figure: Figure) => figure.updater)
|
|
167
|
+
updaterId?: string
|
|
168
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Entity types
|
|
2
|
+
export * from './figure/figure.js'
|
|
3
|
+
export * from './figure/figure-type.js'
|
|
4
|
+
export * from './figure/figure-source.js'
|
|
5
|
+
|
|
6
|
+
// Entities, resolvers
|
|
7
|
+
import { entities as FigureEntities, resolvers as FigureResolvers } from './figure/index.js'
|
|
8
|
+
|
|
9
|
+
export const entities = [...FigureEntities]
|
|
10
|
+
export const resolvers = [...FigureResolvers]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default {}
|