@things-factory/figure-service 10.1.147 → 10.1.150
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.150",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"test:templates": "node --test scripts/templates.test.mts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@hatiolab/figure-model": "^0.5.
|
|
28
|
+
"@hatiolab/figure-model": "^0.5.2",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
31
|
-
"@things-factory/attachment-base": "^10.1.
|
|
32
|
-
"@things-factory/auth-base": "^10.1.
|
|
33
|
-
"@things-factory/env": "^10.1.
|
|
34
|
-
"@things-factory/setting-base": "^10.1.
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
30
|
+
"@things-factory/ai-client-base": "^10.1.149",
|
|
31
|
+
"@things-factory/attachment-base": "^10.1.150",
|
|
32
|
+
"@things-factory/auth-base": "^10.1.150",
|
|
33
|
+
"@things-factory/env": "^10.1.149",
|
|
34
|
+
"@things-factory/setting-base": "^10.1.150",
|
|
35
|
+
"@things-factory/shell": "^10.1.150",
|
|
36
36
|
"graphql-scalars": "^1.22.4",
|
|
37
37
|
"graphql-upload": "^15.0.2",
|
|
38
38
|
"jszip": "^3.10.1",
|
|
39
39
|
"koa-router": "^7.4.0"
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "39c135863f5f7455dcd80b8907550a1c91a86118"
|
|
42
42
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Router from 'koa-router'
|
|
2
|
+
|
|
3
|
+
import { checkPermission } from '@things-factory/auth-base'
|
|
4
|
+
import { getRepository } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
import { meshAttachmentOf } from '../service/figure/figure-mesh-store.js'
|
|
7
|
+
import { GLB_MIMETYPE } from '../service/figure/figure-glb.js'
|
|
8
|
+
import { thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A mesh part's file, by its hash (ADR-0129 decision 8): what the modeller and a board draw a mesh part from, on the
|
|
12
|
+
* server that authored it. A board server serves its own copy at `/figure-library-file/:hash`.
|
|
13
|
+
*
|
|
14
|
+
* The same door as a figure query, and the same scope: this domain's files and its parent's, whose figures it reads.
|
|
15
|
+
* The content is its hash, so it never changes: cached privately for as long as a browser keeps anything.
|
|
16
|
+
*/
|
|
17
|
+
export const figureMeshRouter = new Router()
|
|
18
|
+
|
|
19
|
+
figureMeshRouter.get('/figure-mesh/:hash', async (context, next) => {
|
|
20
|
+
const { domain, user, unsafeIP, prohibitedPrivileges } = context.state
|
|
21
|
+
const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)
|
|
22
|
+
if (!allowed) {
|
|
23
|
+
context.status = 403
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
await sendFigureMesh(context, next, context.params.hash, domain)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** The file of a mesh part the caller may read. Exported for tests, which give their own `files`. */
|
|
30
|
+
export async function sendFigureMesh(
|
|
31
|
+
context: any,
|
|
32
|
+
next: () => Promise<any>,
|
|
33
|
+
hash: string,
|
|
34
|
+
domain: { id: string; parentId?: string },
|
|
35
|
+
files: ThumbnailFiles = thumbnailFiles()
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) {
|
|
38
|
+
context.status = 404
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const kept = await meshAttachmentOf({ getRepository }, [domain.id, domain.parentId].filter(Boolean) as string[], hash)
|
|
42
|
+
if (!kept) {
|
|
43
|
+
context.status = 404
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const ours = () => {
|
|
47
|
+
context.type = GLB_MIMETYPE
|
|
48
|
+
context.set('Cache-Control', 'private, max-age=31536000, immutable')
|
|
49
|
+
context.set('ETag', `"${hash}"`)
|
|
50
|
+
}
|
|
51
|
+
ours()
|
|
52
|
+
context.status = 200
|
|
53
|
+
if (context.fresh) {
|
|
54
|
+
context.status = 304
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
await files.send(context, kept.path!, next)
|
|
58
|
+
ours()
|
|
59
|
+
}
|
package/server/routes.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getRoutePrefixForDomainType } from '@things-factory/shell'
|
|
|
3
3
|
|
|
4
4
|
import { figureThumbnailRouter } from './routers/figure-thumbnail-router.js'
|
|
5
5
|
import { figurePackageRouter } from './routers/figure-package-router.js'
|
|
6
|
+
import { figureMeshRouter } from './routers/figure-mesh-router.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 도메인 경로가 주소에 들어가는 배치인가.
|
|
@@ -20,8 +21,10 @@ process.on('bootstrap-module-domain-private-route' as any, (app, domainPrivateRo
|
|
|
20
21
|
figureThumbnailRouter.allowedMethods()
|
|
21
22
|
)
|
|
22
23
|
domainPrivateRouter.use(`/${getRoutePrefixForDomainType()}/:domain`, figurePackageRouter.routes(), figurePackageRouter.allowedMethods())
|
|
24
|
+
domainPrivateRouter.use(`/${getRoutePrefixForDomainType()}/:domain`, figureMeshRouter.routes(), figureMeshRouter.allowedMethods())
|
|
23
25
|
} else {
|
|
24
26
|
domainPrivateRouter.use('', figureThumbnailRouter.routes(), figureThumbnailRouter.allowedMethods())
|
|
25
27
|
domainPrivateRouter.use('', figurePackageRouter.routes(), figurePackageRouter.allowedMethods())
|
|
28
|
+
domainPrivateRouter.use('', figureMeshRouter.routes(), figureMeshRouter.allowedMethods())
|
|
26
29
|
}
|
|
27
30
|
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a GLB on upload (ADR-0129 decision 6): what a board can draw, and the numbers a mesh part carries.
|
|
3
|
+
* The files are made here, from their JSON: glTF requires every POSITION accessor to state its min and max, which is all
|
|
4
|
+
* the reader reads.
|
|
5
|
+
*/
|
|
6
|
+
import { GlbRefusal, eulerOfRotation, readGlb } from './figure-glb'
|
|
7
|
+
|
|
8
|
+
/** A binary glTF: the JSON chunk, padded to four bytes, and an empty binary chunk. */
|
|
9
|
+
function glb(json: unknown, { version = 2 }: { version?: number } = {}): Uint8Array {
|
|
10
|
+
let text = JSON.stringify(json)
|
|
11
|
+
while (text.length % 4) text += ' '
|
|
12
|
+
const body = new TextEncoder().encode(text)
|
|
13
|
+
const out = new Uint8Array(12 + 8 + body.length + 8)
|
|
14
|
+
const view = new DataView(out.buffer)
|
|
15
|
+
view.setUint32(0, 0x46546c67, true)
|
|
16
|
+
view.setUint32(4, version, true)
|
|
17
|
+
view.setUint32(8, out.length, true)
|
|
18
|
+
view.setUint32(12, body.length, true)
|
|
19
|
+
view.setUint32(16, 0x4e4f534a, true)
|
|
20
|
+
out.set(body, 20)
|
|
21
|
+
view.setUint32(20 + body.length, 0, true)
|
|
22
|
+
view.setUint32(24 + body.length, 0x004e4942, true)
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* A crane: a base (a 2 m × 0.5 m × 0.4 m box, its own origin at its centre) and a hoist below it, a 0.2 m cube with 12
|
|
27
|
+
triangles in two primitives, turned 90° about Y and standing 1 m along X. */
|
|
28
|
+
const quarterY = [0, Math.SQRT1_2, 0, Math.SQRT1_2]
|
|
29
|
+
const crane = (over: Record<string, unknown> = {}) => ({
|
|
30
|
+
asset: { version: '2.0' },
|
|
31
|
+
scene: 0,
|
|
32
|
+
scenes: [{ nodes: [0] }],
|
|
33
|
+
nodes: [
|
|
34
|
+
{ name: 'base', mesh: 0, children: [1] },
|
|
35
|
+
{ name: 'hoist', mesh: 1, translation: [1, -0.5, 0], rotation: quarterY }
|
|
36
|
+
],
|
|
37
|
+
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }, { primitives: [{ attributes: { POSITION: 2 } }, { attributes: { POSITION: 2 } }] }],
|
|
38
|
+
accessors: [
|
|
39
|
+
{ count: 24, min: [-1, -0.25, -0.2], max: [1, 0.25, 0.2] },
|
|
40
|
+
{ count: 36 },
|
|
41
|
+
{ count: 18, min: [-0.1, -0.1, -0.1], max: [0.3, 0.1, 0.1] }
|
|
42
|
+
],
|
|
43
|
+
buffers: [{ byteLength: 0 }],
|
|
44
|
+
...over
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('reading a GLB on upload', () => {
|
|
48
|
+
it('counts the whole file: its bounds in mm, its triangles and its primitives', () => {
|
|
49
|
+
const summary = readGlb(glb(crane()))
|
|
50
|
+
expect(summary.triangles).toBe(12 + 6 + 6)
|
|
51
|
+
expect(summary.primitives).toBe(3)
|
|
52
|
+
expect(summary.unit).toBe('m')
|
|
53
|
+
/* The hoist turned a quarter about Y reaches from 0.9 to 1.1 along X and from −0.3 to 0.1 along Z. */
|
|
54
|
+
expect(summary.bounds).toEqual({ min: { x: -1000, y: -600, z: -300 }, max: { x: 1100, y: 250, z: 200 } })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('lists each node in its own frame, and where it stands in the file', () => {
|
|
58
|
+
const [base, hoist] = readGlb(glb(crane())).nodes
|
|
59
|
+
expect(base).toMatchObject({ index: 0, name: 'base', parent: null, triangles: 24, primitives: 3, position: { x: 0, y: 0, z: 0 } })
|
|
60
|
+
expect(base!.bounds).toEqual({ min: { x: -1000, y: -600, z: -300 }, max: { x: 1100, y: 250, z: 200 } })
|
|
61
|
+
/* The hoist in its own frame is not turned, and is not re-centred: its origin is where the file put it. */
|
|
62
|
+
expect(hoist).toMatchObject({ index: 1, name: 'hoist', parent: 0, triangles: 12, primitives: 2, position: { x: 1000, y: -500, z: 0 }, rotation: { x: 0, y: 90, z: 0 } })
|
|
63
|
+
expect(hoist!.bounds).toEqual({ min: { x: -100, y: -100, z: -100 }, max: { x: 300, y: 100, z: 100 } })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('offers no frame for a node that shears or mirrors, and no part for one without a mesh', () => {
|
|
67
|
+
const sheared = readGlb(glb(crane({ nodes: [{ name: 'base', mesh: 0, children: [1], matrix: [1, 0, 0, 0, 0.5, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, { name: 'hoist', mesh: 1 }] })))
|
|
68
|
+
expect(sheared.nodes[0]!.refused).toBe('sheared')
|
|
69
|
+
const mirrored = readGlb(glb(crane({ nodes: [{ name: 'base', mesh: 0, children: [1], scale: [-1, 1, 1] }, { name: 'hoist', mesh: 1 }] })))
|
|
70
|
+
expect(mirrored.nodes[0]!.refused).toBe('sheared')
|
|
71
|
+
const empty = readGlb(glb(crane({ nodes: [{ name: 'base', mesh: 0, children: [1] }, { name: 'hoist', mesh: 1 }, { name: 'marker' }] })))
|
|
72
|
+
expect(empty.nodes[2]!.refused).toBe('no-mesh')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('a strip and a fan are counted as they draw; lines and points draw no triangles', () => {
|
|
76
|
+
const one = (mode: number, count: number) =>
|
|
77
|
+
readGlb(glb(crane({ nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode }] }], accessors: [{ count, min: [0, 0, 0], max: [1, 1, 1] }] }))).triangles
|
|
78
|
+
expect(one(5, 10)).toBe(8)
|
|
79
|
+
expect(one(6, 10)).toBe(8)
|
|
80
|
+
expect(one(1, 10)).toBe(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('refuses what a board cannot draw as it is, by name', () => {
|
|
84
|
+
const code = (bytes: Uint8Array) => {
|
|
85
|
+
try {
|
|
86
|
+
readGlb(bytes)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return e instanceof GlbRefusal ? e.code : String(e)
|
|
89
|
+
}
|
|
90
|
+
return 'read'
|
|
91
|
+
}
|
|
92
|
+
expect(code(new TextEncoder().encode('{"asset":{"version":"2.0"}}'))).toBe('GLB_NOT_BINARY')
|
|
93
|
+
expect(code(glb(crane(), { version: 1 }))).toBe('GLB_VERSION')
|
|
94
|
+
expect(code(glb(crane({ extensionsRequired: ['KHR_draco_mesh_compression'] })))).toBe('GLB_EXTENSION')
|
|
95
|
+
expect(code(glb(crane({ extensionsRequired: ['EXT_meshopt_compression'] })))).toBe('read')
|
|
96
|
+
expect(code(glb(crane({ buffers: [{ uri: 'crane.bin', byteLength: 10 }] })))).toBe('GLB_EXTERNAL')
|
|
97
|
+
expect(code(glb(crane({ images: [{ uri: 'paint.png' }] })))).toBe('GLB_EXTERNAL')
|
|
98
|
+
expect(code(glb(crane({ meshes: [], nodes: [{ name: 'empty' }] })))).toBe('GLB_NO_MESH')
|
|
99
|
+
expect(code(glb(crane({ accessors: [{ count: 3 }, { count: 3 }, { count: 3 }] })))).toBe('GLB_UNREADABLE')
|
|
100
|
+
const cut = glb(crane())
|
|
101
|
+
expect(code(cut.subarray(0, cut.length - 4))).toBe('GLB_UNREADABLE')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('turns a rotation into degrees in the order rigid@1 turns them', () => {
|
|
105
|
+
const rx = (a: number) => [[1, 0, 0], [0, Math.cos(a), -Math.sin(a)], [0, Math.sin(a), Math.cos(a)]]
|
|
106
|
+
const ry = (a: number) => [[Math.cos(a), 0, Math.sin(a)], [0, 1, 0], [-Math.sin(a), 0, Math.cos(a)]]
|
|
107
|
+
const rz = (a: number) => [[Math.cos(a), -Math.sin(a), 0], [Math.sin(a), Math.cos(a), 0], [0, 0, 1]]
|
|
108
|
+
const m = (a: number[][], b: number[][]) => a.map((row, i) => b[0]!.map((_, k) => row.reduce((s, v, j) => s + v * b[j]![k]!, 0)))
|
|
109
|
+
const d = Math.PI / 180
|
|
110
|
+
expect(eulerOfRotation(m(m(rx(30 * d), ry(20 * d)), rz(-45 * d)))).toEqual({ x: 30, y: 20, z: -45 })
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a GLB file holds, read on upload (ADR-0129 decision 6): whether it is a glTF 2.0 binary the board can draw, and
|
|
3
|
+
* the numbers a mesh part carries -- its bounds in mm, its triangles, its primitives -- for the whole file and for each
|
|
4
|
+
* node of it. The kernel never reads the file (figure-model `mesh@1`, contract §13); these numbers come from here.
|
|
5
|
+
*
|
|
6
|
+
* Nothing is decoded but the JSON chunk. glTF requires every POSITION accessor to carry its min and max, so the bounds
|
|
7
|
+
* are those corners carried through the node transforms; a compressed buffer (Meshopt) keeps them too.
|
|
8
|
+
*
|
|
9
|
+
* A node's frame is its place in the file: its world translation and rotation, in the file's root frame. A part made
|
|
10
|
+
* from a node stands there (its position and rotation), turns about the node's origin, and is drawn with the node's
|
|
11
|
+
* world scale kept in the drawing. A node whose world transform shears or mirrors has no such frame and is not offered.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { FIGURE_MESH_CONTENT_TYPE, FIGURE_MESH_MAX_BYTES } from '@hatiolab/figure-model'
|
|
15
|
+
|
|
16
|
+
/* The package contract's rule (figure-model): the same type and limit a board server holds an import to. */
|
|
17
|
+
export const GLB_MIMETYPE = FIGURE_MESH_CONTENT_TYPE
|
|
18
|
+
/** The largest file kept, in bytes. A board downloads it for every figure that draws it. */
|
|
19
|
+
export const GLB_MAX_BYTES = FIGURE_MESH_MAX_BYTES
|
|
20
|
+
|
|
21
|
+
/** The extensions a board can draw: Meshopt's decoder ships with the loader. Draco and KTX2 wait (ADR-0129 Q4). */
|
|
22
|
+
const DRAWABLE_EXTENSIONS = new Set(['EXT_meshopt_compression', 'KHR_mesh_quantization', 'KHR_materials_emissive_strength', 'KHR_texture_transform'])
|
|
23
|
+
|
|
24
|
+
export type Box = { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }
|
|
25
|
+
|
|
26
|
+
export interface GlbNode {
|
|
27
|
+
/** Its place in the file's node list: what a mesh part names it by. */
|
|
28
|
+
index: number
|
|
29
|
+
name: string
|
|
30
|
+
parent: number | null
|
|
31
|
+
/** The node's subtree, in its own frame, in mm; none when it holds no mesh. */
|
|
32
|
+
bounds: Box | null
|
|
33
|
+
triangles: number
|
|
34
|
+
primitives: number
|
|
35
|
+
/** Where the node stands in the file's root frame: what a part made from it is placed at. */
|
|
36
|
+
position: { x: number; y: number; z: number }
|
|
37
|
+
rotation: { x: number; y: number; z: number }
|
|
38
|
+
/** Why it cannot be a part, when it cannot. */
|
|
39
|
+
refused?: 'no-mesh' | 'sheared'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GlbSummary {
|
|
43
|
+
/** The whole file in its root frame, in mm. */
|
|
44
|
+
bounds: Box
|
|
45
|
+
triangles: number
|
|
46
|
+
primitives: number
|
|
47
|
+
unit: 'm'
|
|
48
|
+
nodes: GlbNode[]
|
|
49
|
+
extensions: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class GlbRefusal extends Error {
|
|
53
|
+
constructor(
|
|
54
|
+
readonly code:
|
|
55
|
+
| 'GLB_NOT_BINARY'
|
|
56
|
+
| 'GLB_VERSION'
|
|
57
|
+
| 'GLB_UNREADABLE'
|
|
58
|
+
| 'GLB_EXTERNAL'
|
|
59
|
+
| 'GLB_EXTENSION'
|
|
60
|
+
| 'GLB_NO_MESH'
|
|
61
|
+
| 'GLB_TOO_LARGE',
|
|
62
|
+
message: string,
|
|
63
|
+
/** What a screen says it with: the extensions it needs, the most bytes kept. */
|
|
64
|
+
readonly params: Record<string, string | number> = {}
|
|
65
|
+
) {
|
|
66
|
+
super(message)
|
|
67
|
+
this.name = 'GlbRefusal'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type M4 = number[] // column-major, as glTF writes it
|
|
72
|
+
const IDENTITY: M4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
|
73
|
+
const mul = (a: M4, b: M4): M4 => {
|
|
74
|
+
const out = new Array(16).fill(0)
|
|
75
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r]! * b[c * 4 + k]!
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
const apply = (m: M4, p: [number, number, number]): [number, number, number] => [
|
|
79
|
+
m[0]! * p[0] + m[4]! * p[1] + m[8]! * p[2] + m[12]!,
|
|
80
|
+
m[1]! * p[0] + m[5]! * p[1] + m[9]! * p[2] + m[13]!,
|
|
81
|
+
m[2]! * p[0] + m[6]! * p[1] + m[10]! * p[2] + m[14]!
|
|
82
|
+
]
|
|
83
|
+
function local(node: any): M4 {
|
|
84
|
+
if (Array.isArray(node.matrix) && node.matrix.length === 16) return node.matrix.map(Number)
|
|
85
|
+
const [tx, ty, tz] = node.translation ?? [0, 0, 0]
|
|
86
|
+
const [qx, qy, qz, qw] = node.rotation ?? [0, 0, 0, 1]
|
|
87
|
+
const [sx, sy, sz] = node.scale ?? [1, 1, 1]
|
|
88
|
+
const r = [
|
|
89
|
+
1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy + qz * qw), 2 * (qx * qz - qy * qw),
|
|
90
|
+
2 * (qx * qy - qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz + qx * qw),
|
|
91
|
+
2 * (qx * qz + qy * qw), 2 * (qy * qz - qx * qw), 1 - 2 * (qx * qx + qy * qy)
|
|
92
|
+
]
|
|
93
|
+
return [r[0]! * sx, r[1]! * sx, r[2]! * sx, 0, r[3]! * sy, r[4]! * sy, r[5]! * sy, 0, r[6]! * sz, r[7]! * sz, r[8]! * sz, 0, tx, ty, tz, 1]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A world matrix as translation and rotation, the scale left in the drawing. Undefined when its axes are not square to
|
|
98
|
+
* each other (a shear) or it mirrors: there is no frame to stand a part in.
|
|
99
|
+
*/
|
|
100
|
+
function frameOf(m: M4): { rotation: number[][]; translation: [number, number, number]; inverse: M4 } | undefined {
|
|
101
|
+
const cols = [0, 1, 2].map(c => [m[c * 4]!, m[c * 4 + 1]!, m[c * 4 + 2]!])
|
|
102
|
+
const len = cols.map(c => Math.hypot(...c))
|
|
103
|
+
if (len.some(l => !(l > 1e-12))) return undefined
|
|
104
|
+
const unit = cols.map((c, i) => c.map(v => v / len[i]!))
|
|
105
|
+
const dot = (a: number[], b: number[]) => a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!
|
|
106
|
+
if (Math.abs(dot(unit[0]!, unit[1]!)) > 1e-6 || Math.abs(dot(unit[0]!, unit[2]!)) > 1e-6 || Math.abs(dot(unit[1]!, unit[2]!)) > 1e-6) return undefined
|
|
107
|
+
const [a, b, c] = unit as [number[], number[], number[]]
|
|
108
|
+
const det = a[0]! * (b[1]! * c[2]! - b[2]! * c[1]!) - b[0]! * (a[1]! * c[2]! - a[2]! * c[1]!) + c[0]! * (a[1]! * b[2]! - a[2]! * b[1]!)
|
|
109
|
+
if (det < 0) return undefined
|
|
110
|
+
/* rotation rows: R[r][c] = unit[c][r] */
|
|
111
|
+
const rotation = [0, 1, 2].map(r => [0, 1, 2].map(col => unit[col]![r]!))
|
|
112
|
+
const translation: [number, number, number] = [m[12]!, m[13]!, m[14]!]
|
|
113
|
+
/* The inverse of the rigid part: Rᵀ and −Rᵀt. */
|
|
114
|
+
const inv: M4 = [...IDENTITY]
|
|
115
|
+
for (let r = 0; r < 3; r++) for (let col = 0; col < 3; col++) inv[col * 4 + r] = rotation[col]![r]!
|
|
116
|
+
for (let r = 0; r < 3; r++) inv[12 + r] = -(rotation[0]![r]! * translation[0] + rotation[1]![r]! * translation[1] + rotation[2]![r]! * translation[2])
|
|
117
|
+
return { rotation, translation, inverse: inv }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A rotation as degrees about X, Y and Z in the order `rigid@1` turns them (Rx · Ry · Rz). */
|
|
121
|
+
export function eulerOfRotation(r: number[][]): { x: number; y: number; z: number } {
|
|
122
|
+
const deg = (a: number) => {
|
|
123
|
+
const d = Math.round(((a * 180) / Math.PI) * 1e6) / 1e6
|
|
124
|
+
return d === 0 ? 0 : d
|
|
125
|
+
}
|
|
126
|
+
const sy = Math.max(-1, Math.min(1, r[0]![2]!))
|
|
127
|
+
const y = Math.asin(sy)
|
|
128
|
+
if (Math.abs(sy) < 1 - 1e-9) return { x: deg(Math.atan2(-r[1]![2]!, r[2]![2]!)), y: deg(y), z: deg(Math.atan2(-r[0]![1]!, r[0]![0]!)) }
|
|
129
|
+
/* About Y by a quarter turn: X and Z turn about one axis and only their sum counts; Z is taken as 0. */
|
|
130
|
+
return { x: deg(Math.atan2(r[2]![1]!, r[1]![1]!)), y: deg(y), z: 0 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const TO_MM = 1000
|
|
134
|
+
const empty = (): Box => ({ min: { x: Infinity, y: Infinity, z: Infinity }, max: { x: -Infinity, y: -Infinity, z: -Infinity } })
|
|
135
|
+
const grow = (box: Box, p: [number, number, number]) => {
|
|
136
|
+
box.min = { x: Math.min(box.min.x, p[0]), y: Math.min(box.min.y, p[1]), z: Math.min(box.min.z, p[2]) }
|
|
137
|
+
box.max = { x: Math.max(box.max.x, p[0]), y: Math.max(box.max.y, p[1]), z: Math.max(box.max.z, p[2]) }
|
|
138
|
+
}
|
|
139
|
+
const round = (v: number) => {
|
|
140
|
+
const r = Math.round(v * 1e6) / 1e6
|
|
141
|
+
return r === 0 ? 0 : r
|
|
142
|
+
}
|
|
143
|
+
const inMm = (box: Box): Box => ({
|
|
144
|
+
min: { x: round(box.min.x * TO_MM), y: round(box.min.y * TO_MM), z: round(box.min.z * TO_MM) },
|
|
145
|
+
max: { x: round(box.max.x * TO_MM), y: round(box.max.y * TO_MM), z: round(box.max.z * TO_MM) }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/** Reads a GLB's JSON chunk, and refuses one a board cannot draw as it is. */
|
|
149
|
+
function jsonOf(bytes: Uint8Array): any {
|
|
150
|
+
if (bytes.length > GLB_MAX_BYTES) throw new GlbRefusal('GLB_TOO_LARGE', `the file is ${bytes.length} bytes; the most kept is ${GLB_MAX_BYTES}`, { max: Math.round(GLB_MAX_BYTES / 1024 / 1024) })
|
|
151
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
152
|
+
if (bytes.length < 20 || view.getUint32(0, true) !== 0x46546c67) throw new GlbRefusal('GLB_NOT_BINARY', 'not a binary glTF (.glb) file')
|
|
153
|
+
if (view.getUint32(4, true) !== 2) throw new GlbRefusal('GLB_VERSION', `glTF ${view.getUint32(4, true)}; only glTF 2.0 is read`)
|
|
154
|
+
if (view.getUint32(8, true) !== bytes.length) throw new GlbRefusal('GLB_UNREADABLE', 'the file is cut short or has bytes past its end')
|
|
155
|
+
const length = view.getUint32(12, true)
|
|
156
|
+
if (view.getUint32(16, true) !== 0x4e4f534a || 20 + length > bytes.length) throw new GlbRefusal('GLB_UNREADABLE', 'its first chunk is not its JSON')
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + length)))
|
|
159
|
+
} catch {
|
|
160
|
+
throw new GlbRefusal('GLB_UNREADABLE', 'its JSON chunk is not JSON')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readGlb(bytes: Uint8Array): GlbSummary {
|
|
165
|
+
const gltf = jsonOf(bytes)
|
|
166
|
+
if (String(gltf?.asset?.version ?? '').split('.')[0] !== '2') throw new GlbRefusal('GLB_VERSION', `glTF ${gltf?.asset?.version}; only glTF 2.0 is read`)
|
|
167
|
+
const required: string[] = Array.isArray(gltf.extensionsRequired) ? gltf.extensionsRequired : []
|
|
168
|
+
const undrawable = required.filter(e => !DRAWABLE_EXTENSIONS.has(e))
|
|
169
|
+
if (undrawable.length) throw new GlbRefusal('GLB_EXTENSION', `a board cannot draw it yet: it needs ${undrawable.join(', ')}`, { extensions: undrawable.join(', ') })
|
|
170
|
+
/* Self-contained: one buffer, the file's own; no file beside it. */
|
|
171
|
+
if ((gltf.buffers ?? []).some((b: any) => b?.uri !== undefined)) throw new GlbRefusal('GLB_EXTERNAL', 'it reads a buffer from another file')
|
|
172
|
+
if ((gltf.images ?? []).some((i: any) => i?.uri !== undefined && !String(i.uri).startsWith('data:')))
|
|
173
|
+
throw new GlbRefusal('GLB_EXTERNAL', 'it reads a picture from another file')
|
|
174
|
+
|
|
175
|
+
const nodes: any[] = Array.isArray(gltf.nodes) ? gltf.nodes : []
|
|
176
|
+
const meshes: any[] = Array.isArray(gltf.meshes) ? gltf.meshes : []
|
|
177
|
+
const accessors: any[] = Array.isArray(gltf.accessors) ? gltf.accessors : []
|
|
178
|
+
const parent = new Array<number | null>(nodes.length).fill(null)
|
|
179
|
+
nodes.forEach((n, i) => (Array.isArray(n?.children) ? n.children : []).forEach((c: number) => (parent[c] = i)))
|
|
180
|
+
|
|
181
|
+
/* World matrices, from the roots of the scene shown (or every root, when none is named). */
|
|
182
|
+
const world = new Array<M4 | undefined>(nodes.length)
|
|
183
|
+
const scene = gltf.scenes?.[gltf.scene ?? 0]
|
|
184
|
+
const roots: number[] = Array.isArray(scene?.nodes) ? scene.nodes : nodes.map((_, i) => i).filter(i => parent[i] === null)
|
|
185
|
+
const visit = (i: number, above: M4) => {
|
|
186
|
+
if (world[i]) throw new GlbRefusal('GLB_UNREADABLE', `node ${i} is reached twice: the nodes are not a tree`)
|
|
187
|
+
world[i] = mul(above, local(nodes[i]))
|
|
188
|
+
for (const c of nodes[i]?.children ?? []) visit(c, world[i]!)
|
|
189
|
+
}
|
|
190
|
+
roots.forEach(i => visit(i, IDENTITY))
|
|
191
|
+
|
|
192
|
+
/* What one primitive costs and where it reaches, in its mesh's own space. */
|
|
193
|
+
const primitive = (p: any) => {
|
|
194
|
+
const position = accessors[p?.attributes?.POSITION]
|
|
195
|
+
if (!position || !Array.isArray(position.min) || !Array.isArray(position.max))
|
|
196
|
+
throw new GlbRefusal('GLB_UNREADABLE', 'a primitive has no POSITION with its min and max')
|
|
197
|
+
const count = p.indices !== undefined ? Number(accessors[p.indices]?.count) : Number(position.count)
|
|
198
|
+
const mode = p.mode ?? 4
|
|
199
|
+
const triangles = mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(count - 2, 0) : 0
|
|
200
|
+
const [x0, y0, z0] = position.min.map(Number)
|
|
201
|
+
const [x1, y1, z1] = position.max.map(Number)
|
|
202
|
+
const corners: Array<[number, number, number]> = [x0, x1].flatMap(x => [y0, y1].flatMap(y => [z0, z1].map(z => [x, y, z] as [number, number, number])))
|
|
203
|
+
return { triangles, corners }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const drawn = (i: number) => (nodes[i]?.mesh !== undefined ? meshes[nodes[i].mesh]?.primitives ?? [] : [])
|
|
207
|
+
const subtree = (i: number): number[] => [i, ...(nodes[i]?.children ?? []).flatMap((c: number) => subtree(c))]
|
|
208
|
+
|
|
209
|
+
const whole = empty()
|
|
210
|
+
let triangles = 0,
|
|
211
|
+
primitives = 0
|
|
212
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
213
|
+
if (!world[i]) continue
|
|
214
|
+
for (const p of drawn(i)) {
|
|
215
|
+
const one = primitive(p)
|
|
216
|
+
triangles += one.triangles
|
|
217
|
+
primitives++
|
|
218
|
+
for (const c of one.corners) grow(whole, apply(world[i]!, c))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!primitives) throw new GlbRefusal('GLB_NO_MESH', 'it holds no mesh to draw')
|
|
222
|
+
|
|
223
|
+
const listed: GlbNode[] = nodes.map((n, i) => {
|
|
224
|
+
const frame = world[i] ? frameOf(world[i]!) : undefined
|
|
225
|
+
const below = subtree(i).filter(k => world[k])
|
|
226
|
+
const box = empty()
|
|
227
|
+
let t = 0,
|
|
228
|
+
p = 0
|
|
229
|
+
for (const k of below)
|
|
230
|
+
for (const prim of drawn(k)) {
|
|
231
|
+
const one = primitive(prim)
|
|
232
|
+
t += one.triangles
|
|
233
|
+
p++
|
|
234
|
+
if (frame) for (const c of one.corners) grow(box, apply(mul(frame.inverse, world[k]!), c))
|
|
235
|
+
}
|
|
236
|
+
const position = frame ? { x: round(frame.translation[0] * TO_MM), y: round(frame.translation[1] * TO_MM), z: round(frame.translation[2] * TO_MM) } : { x: 0, y: 0, z: 0 }
|
|
237
|
+
return {
|
|
238
|
+
index: i,
|
|
239
|
+
name: typeof n?.name === 'string' ? n.name : `node ${i}`,
|
|
240
|
+
parent: parent[i]!,
|
|
241
|
+
bounds: p && frame ? inMm(box) : null,
|
|
242
|
+
triangles: t,
|
|
243
|
+
primitives: p,
|
|
244
|
+
position,
|
|
245
|
+
rotation: frame ? eulerOfRotation(frame.rotation) : { x: 0, y: 0, z: 0 },
|
|
246
|
+
...(!p ? { refused: 'no-mesh' as const } : !frame ? { refused: 'sheared' as const } : {})
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return { bounds: inMm(whole), triangles, primitives, unit: 'm', nodes: listed, extensions: Array.isArray(gltf.extensionsUsed) ? gltf.extensionsUsed : [] }
|
|
251
|
+
}
|