@things-factory/figure-service 10.1.147 → 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.
Files changed (40) hide show
  1. package/dist-server/routers/figure-mesh-router.d.ts +14 -0
  2. package/dist-server/routers/figure-mesh-router.js +54 -0
  3. package/dist-server/routers/figure-mesh-router.js.map +1 -0
  4. package/dist-server/routes.js +3 -0
  5. package/dist-server/routes.js.map +1 -1
  6. package/dist-server/service/figure/figure-glb.d.ts +62 -0
  7. package/dist-server/service/figure/figure-glb.js +229 -0
  8. package/dist-server/service/figure/figure-glb.js.map +1 -0
  9. package/dist-server/service/figure/figure-mesh-store.d.ts +30 -0
  10. package/dist-server/service/figure/figure-mesh-store.js +80 -0
  11. package/dist-server/service/figure/figure-mesh-store.js.map +1 -0
  12. package/dist-server/service/figure/figure-mutation.d.ts +8 -0
  13. package/dist-server/service/figure/figure-mutation.js +59 -0
  14. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  15. package/dist-server/service/figure/figure-package-export.js +15 -0
  16. package/dist-server/service/figure/figure-package-export.js.map +1 -1
  17. package/dist-server/service/figure/figure-query.d.ts +2 -0
  18. package/dist-server/service/figure/figure-query.js +15 -0
  19. package/dist-server/service/figure/figure-query.js.map +1 -1
  20. package/dist-server/service/figure/figure-type.d.ts +5 -0
  21. package/dist-server/service/figure/figure-type.js +16 -1
  22. package/dist-server/service/figure/figure-type.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +9 -9
  25. package/server/routers/figure-mesh-router.ts +59 -0
  26. package/server/routes.ts +3 -0
  27. package/server/service/figure/figure-glb.test.ts +112 -0
  28. package/server/service/figure/figure-glb.ts +251 -0
  29. package/server/service/figure/figure-mesh-db.test.ts +244 -0
  30. package/server/service/figure/figure-mesh-store.ts +91 -0
  31. package/server/service/figure/figure-mutation.ts +52 -0
  32. package/server/service/figure/figure-package-export.ts +15 -0
  33. package/server/service/figure/figure-query.ts +9 -0
  34. package/server/service/figure/figure-type.ts +10 -0
  35. package/translations/en.json +10 -1
  36. package/translations/es.json +10 -1
  37. package/translations/ja.json +10 -1
  38. package/translations/ko.json +10 -1
  39. package/translations/ms.json +10 -1
  40. 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.147",
3
+ "version": "10.1.149",
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.0",
28
+ "@hatiolab/figure-model": "^0.5.2",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
- "@things-factory/ai-client-base": "^10.1.129",
31
- "@things-factory/attachment-base": "^10.1.147",
32
- "@things-factory/auth-base": "^10.1.147",
33
- "@things-factory/env": "^10.1.20",
34
- "@things-factory/setting-base": "^10.1.147",
35
- "@things-factory/shell": "^10.1.147",
30
+ "@things-factory/ai-client-base": "^10.1.149",
31
+ "@things-factory/attachment-base": "^10.1.149",
32
+ "@things-factory/auth-base": "^10.1.149",
33
+ "@things-factory/env": "^10.1.149",
34
+ "@things-factory/setting-base": "^10.1.149",
35
+ "@things-factory/shell": "^10.1.149",
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": "df93dddb6ac19a27c32fad21e29ca0ce02d08eb6"
41
+ "gitHead": "816f88a9fa6110935a3f71740458db8f7469d2e3"
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
+ }