@things-factory/figure-service 10.1.146 → 10.1.149
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/dist-server/routers/figure-mesh-router.d.ts +14 -0
- package/dist-server/routers/figure-mesh-router.js +54 -0
- package/dist-server/routers/figure-mesh-router.js.map +1 -0
- package/dist-server/routes.js +3 -0
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/figure/figure-glb.d.ts +62 -0
- package/dist-server/service/figure/figure-glb.js +229 -0
- package/dist-server/service/figure/figure-glb.js.map +1 -0
- package/dist-server/service/figure/figure-mesh-store.d.ts +30 -0
- package/dist-server/service/figure/figure-mesh-store.js +80 -0
- package/dist-server/service/figure/figure-mesh-store.js.map +1 -0
- package/dist-server/service/figure/figure-mutation.d.ts +8 -0
- package/dist-server/service/figure/figure-mutation.js +59 -0
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-package-export.js +15 -0
- package/dist-server/service/figure/figure-package-export.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +2 -0
- package/dist-server/service/figure/figure-query.js +15 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +5 -0
- package/dist-server/service/figure/figure-type.js +16 -1
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/server/routers/figure-mesh-router.ts +59 -0
- package/server/routes.ts +3 -0
- package/server/service/figure/figure-glb.test.ts +112 -0
- package/server/service/figure/figure-glb.ts +251 -0
- package/server/service/figure/figure-mesh-db.test.ts +244 -0
- package/server/service/figure/figure-mesh-store.ts +91 -0
- package/server/service/figure/figure-mutation.ts +52 -0
- package/server/service/figure/figure-package-export.ts +15 -0
- package/server/service/figure/figure-query.ts +9 -0
- package/server/service/figure/figure-type.ts +10 -0
- package/translations/en.json +10 -1
- package/translations/es.json +10 -1
- package/translations/ja.json +10 -1
- package/translations/ko.json +10 -1
- package/translations/ms.json +10 -1
- package/translations/zh.json +10 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mesh part's file on the authoring server (ADR-0129 decisions 5, 6 and 8), on sqlite through the real doors: kept
|
|
3
|
+
* once per content per domain after it is read, refused by name when a board cannot draw it, required by every save
|
|
4
|
+
* that names it, served behind the figure permission from this domain or its parent, and carried by a package.
|
|
5
|
+
* The files go to a map instead of the configured storage.
|
|
6
|
+
*/
|
|
7
|
+
import type { DataSource } from 'typeorm'
|
|
8
|
+
|
|
9
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
10
|
+
const shell = require('@things-factory/shell')
|
|
11
|
+
const authBase = require('@things-factory/auth-base')
|
|
12
|
+
const attachmentBase = require('@things-factory/attachment-base')
|
|
13
|
+
const settingBase = require('@things-factory/setting-base')
|
|
14
|
+
const JSZip = require('jszip')
|
|
15
|
+
const { createFigureAsset, addFigurePrimitive, serializeFigureAsset, readFigurePackage } = require('@hatiolab/figure-model')
|
|
16
|
+
|
|
17
|
+
let dataSource: DataSource | null = null
|
|
18
|
+
let Figure: any, FigureHistory: any, FigureReplacedDraft: any, FigureMutation: any
|
|
19
|
+
let keepMesh: any, useThumbnailFiles: any, sha256Of: any, sendFigureMesh: any, figurePackageOf: any
|
|
20
|
+
|
|
21
|
+
function loadCompiled(modulePath: string): any {
|
|
22
|
+
const { existsSync } = require('fs')
|
|
23
|
+
const { join } = require('path')
|
|
24
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
25
|
+
if (!existsSync(resolved))
|
|
26
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
|
|
27
|
+
return require(resolved)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mapFiles() {
|
|
31
|
+
const kept = new Map<string, Buffer>()
|
|
32
|
+
let n = 0
|
|
33
|
+
return {
|
|
34
|
+
kept,
|
|
35
|
+
async put(bytes: Buffer, _mimetype: string, name: string) {
|
|
36
|
+
const id = `00000000-0000-4000-8000-${String(++n).padStart(12, '0')}`
|
|
37
|
+
const path = `${id}.${name.split('.').pop()}`
|
|
38
|
+
kept.set(path, bytes)
|
|
39
|
+
return { id, path, size: bytes.length }
|
|
40
|
+
},
|
|
41
|
+
async send(koa: any, path: string) {
|
|
42
|
+
koa.body = kept.get(path)
|
|
43
|
+
},
|
|
44
|
+
async read(path: string) {
|
|
45
|
+
return kept.get(path)!
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A GLB of one box, 2 m along X: its JSON, padded, and an empty binary chunk. `tag` makes another file of it. */
|
|
51
|
+
function glb(tag = 'crane', over: Record<string, unknown> = {}): Buffer {
|
|
52
|
+
let text = JSON.stringify({
|
|
53
|
+
asset: { version: '2.0', generator: tag },
|
|
54
|
+
scenes: [{ nodes: [0] }],
|
|
55
|
+
nodes: [{ name: 'body', mesh: 0 }],
|
|
56
|
+
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
|
|
57
|
+
accessors: [{ count: 24, min: [-1, 0, -0.2], max: [1, 0.5, 0.2] }, { count: 36 }],
|
|
58
|
+
buffers: [{ byteLength: 0 }],
|
|
59
|
+
...over
|
|
60
|
+
})
|
|
61
|
+
while (text.length % 4) text += ' '
|
|
62
|
+
const body = Buffer.from(text)
|
|
63
|
+
const out = Buffer.alloc(28 + body.length)
|
|
64
|
+
out.writeUInt32LE(0x46546c67, 0)
|
|
65
|
+
out.writeUInt32LE(2, 4)
|
|
66
|
+
out.writeUInt32LE(out.length, 8)
|
|
67
|
+
out.writeUInt32LE(body.length, 12)
|
|
68
|
+
out.writeUInt32LE(0x4e4f534a, 16)
|
|
69
|
+
body.copy(out, 20)
|
|
70
|
+
out.writeUInt32LE(0, 20 + body.length)
|
|
71
|
+
out.writeUInt32LE(0x004e4942, 24 + body.length)
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sourceWith = (hash: string) =>
|
|
76
|
+
serializeFigureAsset(
|
|
77
|
+
addFigurePrimitive(createFigureAsset('Crane'), {
|
|
78
|
+
id: 'body',
|
|
79
|
+
kind: 'mesh',
|
|
80
|
+
dimensions: { size: 2000 },
|
|
81
|
+
mesh: { asset: hash, format: 'glb', unit: 'm', bounds: { min: { x: -1000, y: 0, z: -200 }, max: { x: 1000, y: 500, z: 200 } }, triangles: 12, primitives: 1 },
|
|
82
|
+
position: { x: 0, y: 0, z: 0 }
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
let files: ReturnType<typeof mapFiles>
|
|
87
|
+
let restore: any
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
90
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
91
|
+
FigureReplacedDraft = loadCompiled('service/figure/figure-replaced-draft').FigureReplacedDraft
|
|
92
|
+
FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
|
|
93
|
+
;({ keepMesh } = loadCompiled('service/figure/figure-mesh-store'))
|
|
94
|
+
;({ useThumbnailFiles, sha256Of } = loadCompiled('service/figure/figure-thumbnail-store'))
|
|
95
|
+
;({ sendFigureMesh } = loadCompiled('routers/figure-mesh-router'))
|
|
96
|
+
;({ figurePackageOf } = loadCompiled('service/figure/figure-package-export'))
|
|
97
|
+
dataSource = new TypeOrmDataSource({
|
|
98
|
+
type: 'sqlite',
|
|
99
|
+
database: ':memory:',
|
|
100
|
+
synchronize: true,
|
|
101
|
+
dropSchema: true,
|
|
102
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
103
|
+
entities: [...shell.entities, ...authBase.entities, ...attachmentBase.entities, ...settingBase.entities, Figure, FigureHistory, FigureReplacedDraft],
|
|
104
|
+
logging: false
|
|
105
|
+
})
|
|
106
|
+
await dataSource.initialize()
|
|
107
|
+
shell.addDataSource('default', dataSource)
|
|
108
|
+
shell.addDataSource('tx', dataSource)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
shell.removeDataSource('default')
|
|
113
|
+
shell.removeDataSource('tx')
|
|
114
|
+
await dataSource?.destroy()
|
|
115
|
+
dataSource = null
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
files = mapFiles()
|
|
120
|
+
restore = useThumbnailFiles(files)
|
|
121
|
+
})
|
|
122
|
+
afterEach(() => useThumbnailFiles(restore))
|
|
123
|
+
|
|
124
|
+
let seq = 0
|
|
125
|
+
const newDomain = async (parent?: any) => {
|
|
126
|
+
const name = `mesh-${++seq}`
|
|
127
|
+
const saved = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name, ...(parent ? { parent } : {}) })
|
|
128
|
+
const domain = await dataSource!.getRepository(shell.Domain).findOneOrFail({ where: { id: saved.id } })
|
|
129
|
+
await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
|
|
130
|
+
return domain
|
|
131
|
+
}
|
|
132
|
+
const meshRows = (domainId: string) => dataSource!.getRepository(attachmentBase.Attachment).find({ where: { refType: 'figure-mesh', domain: { id: domainId } } })
|
|
133
|
+
const upload = (domain: any, bytes: Buffer) =>
|
|
134
|
+
dataSource!.transaction(tx =>
|
|
135
|
+
new FigureMutation().uploadFigureMesh(Promise.resolve({ createReadStream: () => require('stream').Readable.from([bytes]) }), { state: { domain, user: undefined, tx } })
|
|
136
|
+
)
|
|
137
|
+
const save = (domain: any, source: string) =>
|
|
138
|
+
dataSource!.transaction(tx =>
|
|
139
|
+
new FigureMutation().saveFigure(source, { state: { domain, user: undefined, tx } }, undefined, undefined, require('crypto').randomUUID())
|
|
140
|
+
)
|
|
141
|
+
const koa = () => ({ status: 404, type: '', body: undefined as any, headers: {} as Record<string, string>, set(k: string, v: string) { this.headers[k] = v }, fresh: false })
|
|
142
|
+
|
|
143
|
+
describe('keeping a GLB', () => {
|
|
144
|
+
it('reads it, keeps it once per content in a domain, and answers its hash and what it holds', async () => {
|
|
145
|
+
const domain = await newDomain()
|
|
146
|
+
const first = await upload(domain, glb())
|
|
147
|
+
const again = await upload(domain, glb())
|
|
148
|
+
expect(first.hash).toBe(sha256Of(glb()))
|
|
149
|
+
expect(again.hash).toBe(first.hash)
|
|
150
|
+
expect(first.summary).toMatchObject({ triangles: 12, primitives: 1, bounds: { min: { x: -1000, y: 0, z: -200 }, max: { x: 1000, y: 500, z: 200 } } })
|
|
151
|
+
expect(await meshRows(domain.id)).toHaveLength(1)
|
|
152
|
+
expect(files.kept.size).toBe(1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('refuses a file a board cannot draw, by its reason, and keeps nothing', async () => {
|
|
156
|
+
const domain = await newDomain()
|
|
157
|
+
await expect(upload(domain, glb('draco', { extensionsRequired: ['KHR_draco_mesh_compression'] }))).rejects.toMatchObject({
|
|
158
|
+
code: 'figure-mesh-extension',
|
|
159
|
+
params: { extensions: 'KHR_draco_mesh_compression' }
|
|
160
|
+
})
|
|
161
|
+
await expect(upload(domain, Buffer.from('not a model'))).rejects.toMatchObject({ code: 'figure-mesh-not-glb' })
|
|
162
|
+
expect(await meshRows(domain.id)).toHaveLength(0)
|
|
163
|
+
expect(files.kept.size).toBe(0)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('a save names only files the domain holds', () => {
|
|
168
|
+
it('refuses a figure whose mesh file was never kept here, and saves it once it is', async () => {
|
|
169
|
+
const domain = await newDomain()
|
|
170
|
+
const hash = sha256Of(glb())
|
|
171
|
+
await expect(save(domain, sourceWith(hash))).rejects.toMatchObject({ code: 'figure-mesh-missing' })
|
|
172
|
+
expect(await dataSource!.getRepository(Figure).count({ where: { domain: { id: domain.id } } })).toBe(0)
|
|
173
|
+
await upload(domain, glb())
|
|
174
|
+
const saved = await save(domain, sourceWith(hash))
|
|
175
|
+
expect(saved.id).toBeTruthy()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("a child domain may name its parent's files, and not another domain's", async () => {
|
|
179
|
+
const parent = await newDomain()
|
|
180
|
+
const child = await newDomain(parent)
|
|
181
|
+
const stranger = await newDomain()
|
|
182
|
+
const hash = (await upload(parent, glb())).hash
|
|
183
|
+
await expect(save(child, sourceWith(hash))).resolves.toBeTruthy()
|
|
184
|
+
await expect(save(stranger, sourceWith(hash))).rejects.toMatchObject({ code: 'figure-mesh-missing' })
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('serving a mesh file', () => {
|
|
189
|
+
it("serves it from this domain or its parent, with its hash as its tag, and never another domain's", async () => {
|
|
190
|
+
const parent = await newDomain()
|
|
191
|
+
const child = await newDomain(parent)
|
|
192
|
+
const stranger = await newDomain()
|
|
193
|
+
const hash = (await upload(parent, glb())).hash
|
|
194
|
+
const asked = async (domain: any) => {
|
|
195
|
+
const k = koa()
|
|
196
|
+
await sendFigureMesh(k, async () => {}, hash, domain, files)
|
|
197
|
+
return k
|
|
198
|
+
}
|
|
199
|
+
const ok = await asked(child)
|
|
200
|
+
expect(ok.status).toBe(200)
|
|
201
|
+
expect(ok.type).toBe('model/gltf-binary')
|
|
202
|
+
expect(ok.headers.ETag).toBe(`"${hash}"`)
|
|
203
|
+
expect(Buffer.compare(ok.body, glb())).toBe(0)
|
|
204
|
+
expect((await asked(stranger)).status).toBe(404)
|
|
205
|
+
const k = koa()
|
|
206
|
+
await sendFigureMesh(k, async () => {}, '../etc/passwd', child, files)
|
|
207
|
+
expect(k.status).toBe(404)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('a package carries the files its releases draw from', () => {
|
|
212
|
+
const release = async (domain: any, hash: string) => {
|
|
213
|
+
const figure = await save(domain, sourceWith(hash))
|
|
214
|
+
await dataSource!.getRepository(Figure).update({ id: figure.id }, { namespace: 'system', type: 'crane' })
|
|
215
|
+
await dataSource!.getRepository(FigureHistory).save({
|
|
216
|
+
originalId: figure.id, version: 1, type: 'crane', name: 'Crane', source: sourceWith(hash), state: 'released', domain
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
const unpack = async (bytes: Buffer) => {
|
|
220
|
+
const zip = await JSZip.loadAsync(bytes)
|
|
221
|
+
const out = new Map<string, Uint8Array>()
|
|
222
|
+
for (const name of Object.keys(zip.files)) out.set(name, await zip.files[name].async('uint8array'))
|
|
223
|
+
return readFigurePackage(out, (b: Uint8Array) => sha256Of(Buffer.from(b)))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it('writes the GLB as a mesh blob a board reads back whole', async () => {
|
|
227
|
+
const domain = await newDomain()
|
|
228
|
+
const hash = (await upload(domain, glb())).hash
|
|
229
|
+
await release(domain, hash)
|
|
230
|
+
const packed = await figurePackageOf({ refs: ['system/crane@1'], domain, exportedBy: null, sourceUrl: 'https://figure.example', files })
|
|
231
|
+
const read = await unpack(packed.bytes)
|
|
232
|
+
expect(read.problems).toEqual([])
|
|
233
|
+
expect(read.refused).toEqual([])
|
|
234
|
+
expect(read.blobs.get(hash)?.contentType).toBe('model/gltf-binary')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('refuses a package whose release draws from a file that is gone', async () => {
|
|
238
|
+
const domain = await newDomain()
|
|
239
|
+
const hash = (await upload(domain, glb())).hash
|
|
240
|
+
await release(domain, hash)
|
|
241
|
+
files.kept.clear()
|
|
242
|
+
await expect(figurePackageOf({ refs: ['system/crane@1'], domain, exportedBy: null, sourceUrl: 'u', files })).rejects.toMatchObject({ code: 'figure-package-mesh-missing' })
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { In } from 'typeorm'
|
|
2
|
+
|
|
3
|
+
import { Attachment } from '@things-factory/attachment-base'
|
|
4
|
+
import { figureFilesOf } from '@hatiolab/figure-model'
|
|
5
|
+
import { Refusal } from '@things-factory/shell'
|
|
6
|
+
|
|
7
|
+
import { GLB_MIMETYPE, readGlb, type GlbSummary } from './figure-glb.js'
|
|
8
|
+
import { sha256Of, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* A mesh part's file (ADR-0129 decision 6), kept as a thumbnail is (ADR-0104 decision 6): by attachment-base's storage,
|
|
12
|
+
* one attachment per (domain, content), refType `figure-mesh`, refBy the SHA-256 of the bytes. A figure's source names
|
|
13
|
+
* it by that hash and nothing else; the bytes are served only through `/figure-mesh/:hash`, behind the figure query
|
|
14
|
+
* permission.
|
|
15
|
+
*
|
|
16
|
+
* A file is read before it is kept (`readGlb`): one a board cannot draw is refused by name, never kept under a hash.
|
|
17
|
+
* The file is written before the row that names it, as a picture is: a file cannot be rolled back.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const MESH_REF_TYPE = 'figure-mesh'
|
|
21
|
+
|
|
22
|
+
type Tx = { getRepository: (target: any) => any }
|
|
23
|
+
|
|
24
|
+
/** The attachment holding this file in one of these domains -- the asker's first, then its parent's -- or undefined. */
|
|
25
|
+
export async function meshAttachmentOf(tx: Tx, domainIds: readonly string[], hash: string): Promise<Attachment | undefined> {
|
|
26
|
+
const found: Attachment[] = await tx.getRepository(Attachment).find({
|
|
27
|
+
where: { domain: { id: In([...domainIds]) }, refType: MESH_REF_TYPE, refBy: hash },
|
|
28
|
+
relations: { domain: true }
|
|
29
|
+
})
|
|
30
|
+
return domainIds.map(id => found.find(a => a.domain?.id === id)).find(Boolean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Keeps a GLB and gives its hash and what it holds. Refused, by the reader's code, when a board cannot draw it. */
|
|
34
|
+
export async function keepMesh(
|
|
35
|
+
tx: Tx,
|
|
36
|
+
domain: { id: string },
|
|
37
|
+
user: unknown,
|
|
38
|
+
bytes: Buffer,
|
|
39
|
+
files: ThumbnailFiles = thumbnailFiles()
|
|
40
|
+
): Promise<{ hash: string; summary: GlbSummary }> {
|
|
41
|
+
const summary = readGlb(bytes)
|
|
42
|
+
const hash = sha256Of(bytes)
|
|
43
|
+
if (await meshAttachmentOf(tx, [domain.id], hash)) return { hash, summary }
|
|
44
|
+
const name = `${hash}.glb`
|
|
45
|
+
/* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */
|
|
46
|
+
const put = await files.put(bytes, GLB_MIMETYPE, name, { state: { domain, user, tx } })
|
|
47
|
+
await tx.getRepository(Attachment).save({
|
|
48
|
+
domain,
|
|
49
|
+
creator: user,
|
|
50
|
+
updater: user,
|
|
51
|
+
id: put.id,
|
|
52
|
+
name,
|
|
53
|
+
mimetype: GLB_MIMETYPE,
|
|
54
|
+
encoding: 'binary',
|
|
55
|
+
refType: MESH_REF_TYPE,
|
|
56
|
+
refBy: hash,
|
|
57
|
+
category: 'model',
|
|
58
|
+
size: String(put.size),
|
|
59
|
+
path: put.path,
|
|
60
|
+
...(put.contents ? { contents: put.contents } : {})
|
|
61
|
+
})
|
|
62
|
+
return { hash, summary }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a kept file holds, read again from its bytes: the modeller asks when it reopens a figure. */
|
|
66
|
+
export async function meshSummaryOf(
|
|
67
|
+
tx: Tx,
|
|
68
|
+
domain: { id: string; parentId?: string | null },
|
|
69
|
+
hash: string,
|
|
70
|
+
files: ThumbnailFiles = thumbnailFiles()
|
|
71
|
+
): Promise<GlbSummary | undefined> {
|
|
72
|
+
const kept = await meshAttachmentOf(tx, [domain.id, domain.parentId].filter(Boolean) as string[], hash)
|
|
73
|
+
return kept ? readGlb(await files.read(kept.path!)) : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A source may name only files this domain holds (or its parent, whose figures it reads): a figure that names a file
|
|
78
|
+
* nobody kept cannot be drawn anywhere, and is refused when it is saved, not when a board finds out.
|
|
79
|
+
*/
|
|
80
|
+
export async function requireMeshFiles(tx: Tx, domain: { id: string; parentId?: string | null }, source: string): Promise<void> {
|
|
81
|
+
let parsed: unknown
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(source)
|
|
84
|
+
} catch {
|
|
85
|
+
return /* the format check refuses it, with its own words */
|
|
86
|
+
}
|
|
87
|
+
const ids = [domain.id, domain.parentId].filter(Boolean) as string[]
|
|
88
|
+
for (const hash of figureFilesOf(parsed))
|
|
89
|
+
if (!(await meshAttachmentOf(tx, ids, hash)))
|
|
90
|
+
throw new Refusal('figure-mesh-missing', { hash }, 'the figure names a mesh file this domain does not hold: {hash}. Upload the file again.')
|
|
91
|
+
}
|
|
@@ -17,6 +17,26 @@ import { namespaceForNewFigure, namespaceOf } from './figure-namespace.js'
|
|
|
17
17
|
import { parseSource } from './figure-format.js'
|
|
18
18
|
import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
|
|
19
19
|
import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
|
|
20
|
+
import { keepMesh, requireMeshFiles } from './figure-mesh-store.js'
|
|
21
|
+
import { GlbRefusal, GLB_MAX_BYTES } from './figure-glb.js'
|
|
22
|
+
import { FigureMeshUpload } from './figure-type.js'
|
|
23
|
+
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
|
|
24
|
+
import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* A GLB a board cannot draw, said by its reason (ADR-0129): each reason its own code, so a screen speaks it in its own
|
|
28
|
+
* words (`error.figure-mesh-*`) and never the reader's English.
|
|
29
|
+
*/
|
|
30
|
+
const MESH_REFUSALS: Record<GlbRefusal['code'], string> = {
|
|
31
|
+
GLB_NOT_BINARY: 'figure-mesh-not-glb',
|
|
32
|
+
GLB_VERSION: 'figure-mesh-version',
|
|
33
|
+
GLB_UNREADABLE: 'figure-mesh-unreadable',
|
|
34
|
+
GLB_EXTERNAL: 'figure-mesh-external',
|
|
35
|
+
GLB_EXTENSION: 'figure-mesh-extension',
|
|
36
|
+
GLB_NO_MESH: 'figure-mesh-empty',
|
|
37
|
+
GLB_TOO_LARGE: 'figure-mesh-too-large'
|
|
38
|
+
}
|
|
39
|
+
const meshRefusal = (e: GlbRefusal) => new Refusal(MESH_REFUSALS[e.code], e.params, e.message)
|
|
20
40
|
|
|
21
41
|
/** Display names by language: a language code to a name of 1-128 characters. Refused whole, with the bad entry named. */
|
|
22
42
|
export function assertNames(names: unknown): asserts names is Record<string, string> {
|
|
@@ -64,6 +84,8 @@ export class FigureMutation {
|
|
|
64
84
|
): Promise<Figure> {
|
|
65
85
|
const { domain, user, tx } = context.state
|
|
66
86
|
const repository = tx.getRepository(Figure)
|
|
87
|
+
/* Every file the source draws from is one this domain holds (ADR-0129). */
|
|
88
|
+
await requireMeshFiles(tx, domain, source)
|
|
67
89
|
if (!id) {
|
|
68
90
|
if (!creationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(creationId))
|
|
69
91
|
throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.')
|
|
@@ -111,6 +133,34 @@ export class FigureMutation {
|
|
|
111
133
|
return repository.findOneOrFail({ where: { id, domain: { id: domain.id } } })
|
|
112
134
|
}
|
|
113
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A GLB for a mesh part (ADR-0129 decision 6): read, then kept by its hash. Answers the hash and what the file holds --
|
|
138
|
+
* its bounds in mm, its triangles and primitives, and its nodes -- which the modeller turns into a part. A file a board
|
|
139
|
+
* cannot draw is refused by name and not kept.
|
|
140
|
+
*/
|
|
141
|
+
@Directive('@transaction')
|
|
142
|
+
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
143
|
+
@Mutation(returns => FigureMeshUpload, { description: 'Keeps a GLB for mesh parts and answers its hash and what it holds.' })
|
|
144
|
+
async uploadFigureMesh(@Arg('file', type => GraphQLUpload) file: FileUpload, @Ctx() context: ResolverContext): Promise<FigureMeshUpload> {
|
|
145
|
+
const { domain, user, tx } = context.state
|
|
146
|
+
const { createReadStream } = await file
|
|
147
|
+
const chunks: Buffer[] = []
|
|
148
|
+
let size = 0
|
|
149
|
+
for await (const chunk of createReadStream()) {
|
|
150
|
+
size += chunk.length
|
|
151
|
+
/* Refused as it arrives: a file past the limit is not read whole first. */
|
|
152
|
+
if (size > GLB_MAX_BYTES) throw meshRefusal(new GlbRefusal('GLB_TOO_LARGE', 'too large', { max: Math.round(GLB_MAX_BYTES / 1024 / 1024) }))
|
|
153
|
+
chunks.push(Buffer.from(chunk))
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const { hash, summary } = await keepMesh(tx, domain, user, Buffer.concat(chunks))
|
|
157
|
+
return { hash, summary }
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (e instanceof GlbRefusal) throw meshRefusal(e)
|
|
160
|
+
throw e
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
114
164
|
@Directive('@transaction')
|
|
115
165
|
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
116
166
|
@Mutation(returns => FigureSaveResult, { description: 'Creates a figure. Rejects when the source violates the format.' })
|
|
@@ -139,6 +189,7 @@ export class FigureMutation {
|
|
|
139
189
|
|
|
140
190
|
// 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
|
|
141
191
|
const { source, derived, violations } = parseSource(figure.source)
|
|
192
|
+
await requireMeshFiles(tx, domain, source)
|
|
142
193
|
|
|
143
194
|
const { thumbnail, ...rest } = figure
|
|
144
195
|
const saved = await repository.save({
|
|
@@ -182,6 +233,7 @@ export class FigureMutation {
|
|
|
182
233
|
|
|
183
234
|
if (patch.source !== undefined && patch.source !== figure.source) {
|
|
184
235
|
const parsed = parseSource(patch.source)
|
|
236
|
+
await requireMeshFiles(tx, domain, parsed.source)
|
|
185
237
|
source = parsed.source
|
|
186
238
|
derived = parsed.derived
|
|
187
239
|
violations = parsed.violations
|
|
@@ -2,6 +2,7 @@ import JSZip from 'jszip'
|
|
|
2
2
|
import { In } from 'typeorm'
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
figureFilesOf,
|
|
5
6
|
formatFigureRef,
|
|
6
7
|
parseFigureRef,
|
|
7
8
|
writeFigurePackage,
|
|
@@ -14,6 +15,8 @@ import { getRepository, Refusal } from '@things-factory/shell'
|
|
|
14
15
|
import { Figure } from './figure.js'
|
|
15
16
|
import { FigureHistory } from './figure-history.js'
|
|
16
17
|
import { sha256Of, thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'
|
|
18
|
+
import { meshAttachmentOf } from './figure-mesh-store.js'
|
|
19
|
+
import { GLB_MIMETYPE } from './figure-glb.js'
|
|
17
20
|
|
|
18
21
|
/** The most releases one package carries. */
|
|
19
22
|
export const FIGURE_PACKAGE_MAX = 200
|
|
@@ -55,6 +58,18 @@ export async function figurePackageOf(request: FigurePackageRequest): Promise<{
|
|
|
55
58
|
if (sha256Of(bytes) === history.thumbnailHash) blobs.set(history.thumbnailHash, { bytes, contentType: picture.mimetype })
|
|
56
59
|
}
|
|
57
60
|
}
|
|
61
|
+
/*
|
|
62
|
+
Every file the release draws from goes with it (ADR-0129 decision 5). Looked for in the figure's own domain, then
|
|
63
|
+
the asker's; one that is gone, or whose bytes are not its hash, refuses the package -- a board could not draw it.
|
|
64
|
+
*/
|
|
65
|
+
for (const hash of figureFilesOf(JSON.parse(history.source!))) {
|
|
66
|
+
if (blobs.has(hash)) continue
|
|
67
|
+
const kept = await meshAttachmentOf({ getRepository }, [figure.domainId!, request.domain.id], hash)
|
|
68
|
+
const bytes = kept ? await files.read(kept.path!) : undefined
|
|
69
|
+
if (!bytes || sha256Of(bytes) !== hash)
|
|
70
|
+
throw new Refusal('figure-package-mesh-missing', { ref: formatFigureRef(ref), hash }, '{ref} draws from a mesh file this server does not hold: {hash}.')
|
|
71
|
+
blobs.set(hash, { bytes, contentType: GLB_MIMETYPE })
|
|
72
|
+
}
|
|
58
73
|
releases.push(packageReleaseOf(ref, figure, history, blobs))
|
|
59
74
|
}
|
|
60
75
|
|
|
@@ -20,6 +20,7 @@ import { FigureInspection, FigureList, FigureProposalOutcome } from './figure-ty
|
|
|
20
20
|
import { FigureReplacedDraft } from './figure-replaced-draft.js'
|
|
21
21
|
import { replacedDraftsOf } from './figure-revert.js'
|
|
22
22
|
import { proposalOutcomeOf } from './figure-proposal.js'
|
|
23
|
+
import { meshSummaryOf } from './figure-mesh-store.js'
|
|
23
24
|
|
|
24
25
|
@Resolver(Figure)
|
|
25
26
|
@UseMiddleware(SpeakRefusals)
|
|
@@ -93,6 +94,14 @@ export class FigureQuery {
|
|
|
93
94
|
* 도형의 현재 정본은 저작 중일 수 있다(초안). 배치된 것이 보는 것은 **마지막으로 발행된 판**이고,
|
|
94
95
|
* 그것을 이 자리가 답한다.
|
|
95
96
|
*/
|
|
97
|
+
/** What a kept GLB holds, read again from its bytes (ADR-0129): the modeller asks when it reopens a figure. */
|
|
98
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
99
|
+
@Query(returns => GraphQLJSON, { nullable: true, description: 'What a kept GLB holds, by its hash: bounds in mm, triangles, primitives and nodes. None when this domain does not hold it.' })
|
|
100
|
+
async figureMeshSummary(@Arg('hash') hash: string, @Ctx() context: ResolverContext): Promise<unknown> {
|
|
101
|
+
const { domain, tx } = context.state
|
|
102
|
+
return (await meshSummaryOf(getRepository(Figure, tx).manager, domain, hash)) ?? null
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
/** What became of an AI proposal: the figures a save put it into (ADR-0097 §5.6 item 5). */
|
|
97
106
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
98
107
|
@Query(returns => [FigureProposalOutcome], {
|
|
@@ -206,3 +206,13 @@ export class FigureInspection {
|
|
|
206
206
|
@Field(type => [FigureGateFinding], { description: 'Findings, blocking and advisory together.' })
|
|
207
207
|
findings: FigureGateFinding[]
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/** A GLB kept for mesh parts (ADR-0129): its hash, and what it holds as the upload read it. */
|
|
211
|
+
@ObjectType({ description: 'A GLB kept for mesh parts: its hash and what it holds.' })
|
|
212
|
+
export class FigureMeshUpload {
|
|
213
|
+
@Field({ description: "The file's SHA-256, lower-case hex: what a mesh part names it by." })
|
|
214
|
+
hash: string
|
|
215
|
+
|
|
216
|
+
@Field(type => GraphQLJSON, { description: 'Bounds in mm, triangles, primitives and nodes, as read from the file.' })
|
|
217
|
+
summary: unknown
|
|
218
|
+
}
|
package/translations/en.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "at most {max} figure names can be asked at once, not {count}.",
|
|
107
107
|
"error.figure-package-size": "a package carries 1 to {max} releases, not {count}.",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} is not a release reference of the form namespace/type@version.",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} is not a release this domain can read."
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} is not a release this domain can read.",
|
|
110
|
+
"error.figure-mesh-not-glb": "the file is not a binary glTF (.glb). export it as GLB and upload it again.",
|
|
111
|
+
"error.figure-mesh-version": "the file is not glTF 2.0. export it as glTF 2.0 and upload it again.",
|
|
112
|
+
"error.figure-mesh-unreadable": "the file cannot be read: it is cut short or damaged. export it again and upload it.",
|
|
113
|
+
"error.figure-mesh-external": "the file reads data from other files. export it as one self-contained GLB and upload it again.",
|
|
114
|
+
"error.figure-mesh-extension": "a board cannot draw this file yet: it needs {extensions}. export it without that compression and upload it again.",
|
|
115
|
+
"error.figure-mesh-empty": "the file holds no mesh to draw.",
|
|
116
|
+
"error.figure-mesh-too-large": "the file is larger than {max} MB, the most a board downloads for one part. make it smaller and upload it again.",
|
|
117
|
+
"error.figure-mesh-missing": "this figure uses a mesh file this domain does not hold ({hash}). upload the file again.",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} uses a mesh file this server does not hold ({hash}); it cannot be packaged."
|
|
110
119
|
}
|
package/translations/es.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "se pueden consultar como máximo {max} nombres de figura a la vez, no {count}.",
|
|
107
107
|
"error.figure-package-size": "un paquete contiene de 1 a {max} publicaciones, no {count}.",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} no es una referencia de publicación con el formato namespace/type@version.",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} no es una publicación que este dominio pueda leer."
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} no es una publicación que este dominio pueda leer.",
|
|
110
|
+
"error.figure-mesh-not-glb": "el archivo no es un glTF binario (.glb). expórtelo como GLB y vuelva a subirlo.",
|
|
111
|
+
"error.figure-mesh-version": "el archivo no es glTF 2.0. expórtelo como glTF 2.0 y vuelva a subirlo.",
|
|
112
|
+
"error.figure-mesh-unreadable": "no se puede leer el archivo: está cortado o dañado. expórtelo de nuevo y súbalo.",
|
|
113
|
+
"error.figure-mesh-external": "el archivo lee datos de otros archivos. expórtelo como un único GLB autónomo y vuelva a subirlo.",
|
|
114
|
+
"error.figure-mesh-extension": "un tablero aún no puede dibujar este archivo: necesita {extensions}. expórtelo sin esa compresión y vuelva a subirlo.",
|
|
115
|
+
"error.figure-mesh-empty": "el archivo no contiene ninguna malla que dibujar.",
|
|
116
|
+
"error.figure-mesh-too-large": "el archivo supera los {max} MB, lo máximo que un tablero descarga para una pieza. redúzcalo y vuelva a subirlo.",
|
|
117
|
+
"error.figure-mesh-missing": "esta figura usa un archivo de malla que este dominio no tiene ({hash}). vuelva a subir el archivo.",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} usa un archivo de malla que este servidor no tiene ({hash}); no se puede empaquetar."
|
|
110
119
|
}
|
package/translations/ja.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "一度に問い合わせられる Figure 名は {max} 個までです({count} 個)。",
|
|
107
107
|
"error.figure-package-size": "パッケージに含められるリリースは 1〜{max} 個です({count} 個)。",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} は namespace/type@version 形式のリリース参照ではありません。",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} はこのドメインで読み込めるリリースではありません。"
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} はこのドメインで読み込めるリリースではありません。",
|
|
110
|
+
"error.figure-mesh-not-glb": "ファイルがバイナリ glTF(.glb)ではありません。GLB で書き出して再度アップロードしてください。",
|
|
111
|
+
"error.figure-mesh-version": "ファイルが glTF 2.0 ではありません。glTF 2.0 で書き出して再度アップロードしてください。",
|
|
112
|
+
"error.figure-mesh-unreadable": "ファイルを読めません。途中で切れているか破損しています。書き出し直してアップロードしてください。",
|
|
113
|
+
"error.figure-mesh-external": "ファイルが他のファイルのデータを読みます。1 つにまとめた GLB で書き出して再度アップロードしてください。",
|
|
114
|
+
"error.figure-mesh-extension": "ボードはまだこのファイルを描けません: {extensions} が必要です。その圧縮なしで書き出して再度アップロードしてください。",
|
|
115
|
+
"error.figure-mesh-empty": "ファイルに描画するメッシュがありません。",
|
|
116
|
+
"error.figure-mesh-too-large": "ファイルが {max} MB を超えています。ボードが部品 1 つにダウンロードする上限です。小さくして再度アップロードしてください。",
|
|
117
|
+
"error.figure-mesh-missing": "この図形が使うメッシュファイル({hash})がこのドメインにありません。ファイルを再度アップロードしてください。",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} が使うメッシュファイル({hash})がこのサーバーにないため、パッケージにできません。"
|
|
110
119
|
}
|
package/translations/ko.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "Figure 이름은 한 번에 {max}개까지 물을 수 있습니다({count}개).",
|
|
107
107
|
"error.figure-package-size": "꾸러미에는 발행판을 1~{max}개 담을 수 있습니다({count}개).",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} 은 namespace/type@버전 형식의 발행판 참조가 아닙니다.",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} 은 이 도메인에서 읽을 수 있는 발행판이 아닙니다."
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} 은 이 도메인에서 읽을 수 있는 발행판이 아닙니다.",
|
|
110
|
+
"error.figure-mesh-not-glb": "파일이 바이너리 glTF(.glb)가 아닙니다. GLB 로 내보내 다시 올려 주세요.",
|
|
111
|
+
"error.figure-mesh-version": "파일이 glTF 2.0 이 아닙니다. glTF 2.0 으로 내보내 다시 올려 주세요.",
|
|
112
|
+
"error.figure-mesh-unreadable": "파일을 읽을 수 없습니다. 잘렸거나 손상되었습니다. 다시 내보내 올려 주세요.",
|
|
113
|
+
"error.figure-mesh-external": "파일이 다른 파일의 데이터를 읽습니다. 하나로 묶은 GLB 로 내보내 다시 올려 주세요.",
|
|
114
|
+
"error.figure-mesh-extension": "보드가 아직 이 파일을 그리지 못합니다: {extensions} 가 필요합니다. 그 압축 없이 내보내 다시 올려 주세요.",
|
|
115
|
+
"error.figure-mesh-empty": "파일에 그릴 메시가 없습니다.",
|
|
116
|
+
"error.figure-mesh-too-large": "파일이 {max} MB 보다 큽니다. 보드가 부품 하나에 내려받는 최대 크기입니다. 줄여서 다시 올려 주세요.",
|
|
117
|
+
"error.figure-mesh-missing": "이 도형이 쓰는 메시 파일({hash})이 이 도메인에 없습니다. 파일을 다시 올려 주세요.",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} 가 쓰는 메시 파일({hash})이 이 서버에 없어 묶을 수 없습니다."
|
|
110
119
|
}
|
package/translations/ms.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "paling banyak {max} nama figure boleh ditanya sekali gus, bukan {count}.",
|
|
107
107
|
"error.figure-package-size": "pakej membawa 1 hingga {max} terbitan, bukan {count}.",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} bukan rujukan terbitan dalam bentuk namespace/type@version.",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} bukan terbitan yang boleh dibaca oleh domain ini."
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} bukan terbitan yang boleh dibaca oleh domain ini.",
|
|
110
|
+
"error.figure-mesh-not-glb": "fail ini bukan glTF binari (.glb). eksport sebagai GLB dan muat naik semula.",
|
|
111
|
+
"error.figure-mesh-version": "fail ini bukan glTF 2.0. eksport sebagai glTF 2.0 dan muat naik semula.",
|
|
112
|
+
"error.figure-mesh-unreadable": "fail tidak dapat dibaca: ia terpotong atau rosak. eksport semula dan muat naik.",
|
|
113
|
+
"error.figure-mesh-external": "fail ini membaca data daripada fail lain. eksport sebagai satu GLB yang lengkap dan muat naik semula.",
|
|
114
|
+
"error.figure-mesh-extension": "papan belum dapat melukis fail ini: ia memerlukan {extensions}. eksport tanpa pemampatan itu dan muat naik semula.",
|
|
115
|
+
"error.figure-mesh-empty": "fail ini tidak mengandungi jejaring untuk dilukis.",
|
|
116
|
+
"error.figure-mesh-too-large": "fail ini melebihi {max} MB, had muat turun papan untuk satu bahagian. kecilkan dan muat naik semula.",
|
|
117
|
+
"error.figure-mesh-missing": "figura ini menggunakan fail jejaring yang tiada dalam domain ini ({hash}). muat naik fail itu semula.",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} menggunakan fail jejaring yang tiada pada pelayan ini ({hash}); ia tidak dapat dipakej."
|
|
110
119
|
}
|
package/translations/zh.json
CHANGED
|
@@ -106,5 +106,14 @@
|
|
|
106
106
|
"error.figure-names-too-many": "一次最多可查询 {max} 个图形名称,而不是 {count} 个。",
|
|
107
107
|
"error.figure-package-size": "一个包可包含 1 到 {max} 个发布版本,而不是 {count} 个。",
|
|
108
108
|
"error.figure-ref-malformed": "{ref} 不是 namespace/type@version 形式的发布版本引用。",
|
|
109
|
-
"error.figure-package-release-not-found": "{ref} 不是此域可以读取的发布版本。"
|
|
109
|
+
"error.figure-package-release-not-found": "{ref} 不是此域可以读取的发布版本。",
|
|
110
|
+
"error.figure-mesh-not-glb": "文件不是二进制 glTF(.glb)。请导出为 GLB 后重新上传。",
|
|
111
|
+
"error.figure-mesh-version": "文件不是 glTF 2.0。请导出为 glTF 2.0 后重新上传。",
|
|
112
|
+
"error.figure-mesh-unreadable": "无法读取文件:文件被截断或已损坏。请重新导出后上传。",
|
|
113
|
+
"error.figure-mesh-external": "文件会读取其他文件中的数据。请导出为单个自包含的 GLB 后重新上传。",
|
|
114
|
+
"error.figure-mesh-extension": "看板暂时无法绘制此文件:需要 {extensions}。请不使用该压缩重新导出后上传。",
|
|
115
|
+
"error.figure-mesh-empty": "文件中没有可绘制的网格。",
|
|
116
|
+
"error.figure-mesh-too-large": "文件大于 {max} MB,这是看板为一个部件下载的上限。请缩小后重新上传。",
|
|
117
|
+
"error.figure-mesh-missing": "此图形使用的网格文件({hash})不在此域中。请重新上传该文件。",
|
|
118
|
+
"error.figure-package-mesh-missing": "{ref} 使用的网格文件({hash})不在此服务器上,无法打包。"
|
|
110
119
|
}
|