lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
package/package.json
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-assets",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.4.10",
|
|
4
4
|
"description": "LeCodes asset pipeline: FBX → GLB (ufbx), clip merging by bone name, texture embedding, GLB doctor (bone cap, texture normalize, KTX2).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"lecodes-assets": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"bun": "./src/index.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./cli": {
|
|
16
|
+
"types": "./src/cli.ts",
|
|
17
|
+
"bun": "./src/cli.ts",
|
|
18
|
+
"default": "./dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"./fbx/*": "./src/fbx/*.ts",
|
|
21
|
+
"./retarget/*": "./src/retarget/*.ts",
|
|
22
|
+
"./unreal/*": "./src/unreal/*.ts"
|
|
12
23
|
},
|
|
13
24
|
"files": [
|
|
14
25
|
"dist",
|
|
26
|
+
"src",
|
|
15
27
|
"README.md"
|
|
16
28
|
],
|
|
17
29
|
"engines": {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// glTF document → BVH: one clip of a skeleton (a rig the BVH reader made, or any rig whose nodes ARE
|
|
2
|
+
// the joints) written in the layout Kimodo reads back (`kimodo_convert x.bvh x.npz
|
|
3
|
+
// --bvh_standard_tpose`): a zero `Root` with six channels over the root joint (six channels), every
|
|
4
|
+
// other joint three ZYX rotation channels, no End Sites, centimetres, the rest pose = the nodes'
|
|
5
|
+
// rest translations (their offsets) with identity rotations — i.e. the file's rest is whatever the
|
|
6
|
+
// document's rest is (the standard T-pose for a rig read from Kimodo's `--bvh_standard_tpose` export).
|
|
7
|
+
// The clip is resampled at `fps` (nlerp between keys).
|
|
8
|
+
|
|
9
|
+
import type { Document, Node } from "@gltf-transform/core"
|
|
10
|
+
|
|
11
|
+
type Quat = [number, number, number, number]
|
|
12
|
+
type Vec3 = [number, number, number]
|
|
13
|
+
|
|
14
|
+
export type BvhWriteOptions = {
|
|
15
|
+
/** the clip to write (default: the document's first) */
|
|
16
|
+
clip?: string
|
|
17
|
+
/** frames per second of the output (default 30 — Kimodo's rate) */
|
|
18
|
+
fps?: number
|
|
19
|
+
/** metres per BVH unit (default 0.01: centimetres) */
|
|
20
|
+
unit?: number
|
|
21
|
+
/** the wrapper root's name over the skeleton root (default "Root"); "" = none, the skeleton root is the BVH root */
|
|
22
|
+
wrapperRoot?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const nlerp = (a: Quat, b: Quat, t: number): Quat => {
|
|
26
|
+
const d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
|
|
27
|
+
const s = d < 0 ? -1 : 1
|
|
28
|
+
const q: Quat = [ a[0] + (s * b[0] - a[0]) * t, a[1] + (s * b[1] - a[1]) * t, a[2] + (s * b[2] - a[2]) * t, a[3] + (s * b[3] - a[3]) * t ]
|
|
29
|
+
const n = Math.hypot(q[0], q[1], q[2], q[3]) || 1
|
|
30
|
+
return [ q[0] / n, q[1] / n, q[2] / n, q[3] / n ]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Z·Y·X Euler angles (degrees, in channel order Zrotation Yrotation Xrotation) of a unit quaternion:
|
|
34
|
+
* R = Rz(γ)·Ry(β)·Rx(α) → β = asin(−R20), α = atan2(R21, R22), γ = atan2(R10, R00). */
|
|
35
|
+
const eulerZYX = (q: Quat): [number, number, number] => {
|
|
36
|
+
const [ x, y, z, w ] = q
|
|
37
|
+
const r00 = 1 - 2 * (y * y + z * z), r10 = 2 * (x * y + z * w), r20 = 2 * (x * z - y * w)
|
|
38
|
+
const r21 = 2 * (y * z + x * w), r22 = 1 - 2 * (x * x + y * y)
|
|
39
|
+
const b = Math.asin(Math.max(-1, Math.min(1, -r20)))
|
|
40
|
+
const D = 180 / Math.PI
|
|
41
|
+
if (Math.abs(r20) > 0.999999) {
|
|
42
|
+
// gimbal lock: fold everything into γ
|
|
43
|
+
const r01 = 2 * (x * y - z * w), r11 = 1 - 2 * (x * x + z * z)
|
|
44
|
+
return [ Math.atan2(-r01, r11) * D, b * D, 0 ]
|
|
45
|
+
}
|
|
46
|
+
return [ Math.atan2(r10, r00) * D, b * D, Math.atan2(r21, r22) * D ]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const f = (v: number): string => {
|
|
50
|
+
const s = (Math.abs(v) < 5e-7 ? 0 : v).toFixed(6)
|
|
51
|
+
return s.replace(/\.?0+$/, "") || "0"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const documentToBvh = (doc: Document, opts: BvhWriteOptions = {}): { text: string, report: { clip: string, joints: number, frames: number, fps: number } } => {
|
|
55
|
+
const fps = opts.fps ?? 30
|
|
56
|
+
const U = opts.unit ?? 0.01
|
|
57
|
+
const root = doc.getRoot()
|
|
58
|
+
const anim = opts.clip ? root.listAnimations().find((a) => a.getName() === opts.clip) : root.listAnimations()[0]
|
|
59
|
+
if (!anim) throw new Error(opts.clip ? `no clip "${opts.clip}" (have ${root.listAnimations().map((a) => a.getName()).join(", ")})` : "no animation in the document")
|
|
60
|
+
|
|
61
|
+
// the skeleton: the joints of the first skin if there is one, else every node under the scene
|
|
62
|
+
const skin = root.listSkins()[0]
|
|
63
|
+
const joints = skin ? skin.listJoints() : root.listNodes().filter((n) => !n.getMesh())
|
|
64
|
+
const jointSet = new Set(joints)
|
|
65
|
+
const skelRoots = joints.filter((n) => { const p = n.getParentNode(); return !p || !jointSet.has(p) })
|
|
66
|
+
if (skelRoots.length !== 1) throw new Error(`the skeleton must have one root joint (found ${skelRoots.map((n) => n.getName()).join(", ") || "none"})`)
|
|
67
|
+
const skelRoot = skelRoots[0]
|
|
68
|
+
|
|
69
|
+
// tracks: per joint, the sampler for rotation / translation
|
|
70
|
+
const tracks = new Map<Node, { rot?: { t: Float32Array, v: Float32Array }, pos?: { t: Float32Array, v: Float32Array } }>()
|
|
71
|
+
for (const ch of anim.listChannels()) {
|
|
72
|
+
const node = ch.getTargetNode(), s = ch.getSampler()
|
|
73
|
+
if (!node || !s || !jointSet.has(node)) continue
|
|
74
|
+
const t = s.getInput()?.getArray() as Float32Array | undefined, v = s.getOutput()?.getArray() as Float32Array | undefined
|
|
75
|
+
if (!t || !v) continue
|
|
76
|
+
const rec = tracks.get(node) ?? {}
|
|
77
|
+
if (ch.getTargetPath() === "rotation") rec.rot = { t, v }
|
|
78
|
+
else if (ch.getTargetPath() === "translation") rec.pos = { t, v }
|
|
79
|
+
tracks.set(node, rec)
|
|
80
|
+
}
|
|
81
|
+
let duration = 0
|
|
82
|
+
for (const r of tracks.values()) for (const k of [ r.rot, r.pos ]) if (k) duration = Math.max(duration, k.t[k.t.length - 1])
|
|
83
|
+
const frames = Math.max(1, Math.round(duration * fps) + 1)
|
|
84
|
+
|
|
85
|
+
const sample = (t: Float32Array, v: Float32Array, n: number, time: number, mix: (a: number[], b: number[], k: number) => number[]): number[] => {
|
|
86
|
+
if (time <= t[0]) return Array.from(v.subarray(0, n))
|
|
87
|
+
const last = t.length - 1
|
|
88
|
+
if (time >= t[last]) return Array.from(v.subarray(last * n, last * n + n))
|
|
89
|
+
let i = 0
|
|
90
|
+
while (i < last && t[i + 1] <= time) i++
|
|
91
|
+
const k = (time - t[i]) / Math.max(1e-9, t[i + 1] - t[i])
|
|
92
|
+
return mix(Array.from(v.subarray(i * n, i * n + n)), Array.from(v.subarray((i + 1) * n, (i + 1) * n + n)), k)
|
|
93
|
+
}
|
|
94
|
+
const mixV = (a: number[], b: number[], k: number): number[] => a.map((x, i) => x + (b[i] - x) * k)
|
|
95
|
+
const mixQ = (a: number[], b: number[], k: number): number[] => nlerp(a as Quat, b as Quat, k)
|
|
96
|
+
|
|
97
|
+
// the hierarchy
|
|
98
|
+
const order: Node[] = []
|
|
99
|
+
const lines: string[] = [ "HIERARCHY" ]
|
|
100
|
+
const wrapper = opts.wrapperRoot ?? "Root"
|
|
101
|
+
const emit = (n: Node, depth: number, isRoot: boolean): void => {
|
|
102
|
+
order.push(n)
|
|
103
|
+
const pad = " ".repeat(depth)
|
|
104
|
+
const o = n.getTranslation()
|
|
105
|
+
lines.push(`${pad}${isRoot && !wrapper ? "ROOT" : "JOINT"} ${n.getName()}`)
|
|
106
|
+
lines.push(`${pad}{`)
|
|
107
|
+
lines.push(`${pad} OFFSET ${f(o[0] / U)} ${f(o[1] / U)} ${f(o[2] / U)}`)
|
|
108
|
+
lines.push(`${pad} CHANNELS ${isRoot ? "6 Xposition Yposition Zposition " : "3 "}Zrotation Yrotation Xrotation`)
|
|
109
|
+
for (const c of n.listChildren()) if (jointSet.has(c)) emit(c, depth + 1, false)
|
|
110
|
+
lines.push(`${pad}}`)
|
|
111
|
+
}
|
|
112
|
+
if (wrapper) {
|
|
113
|
+
lines.push(`ROOT ${wrapper}`, "{", " OFFSET 0.0 0.0 0.0", " CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation")
|
|
114
|
+
emit(skelRoot, 1, true)
|
|
115
|
+
lines.push("}")
|
|
116
|
+
} else emit(skelRoot, 0, true)
|
|
117
|
+
|
|
118
|
+
lines.push("MOTION", `Frames: ${frames}`, `Frame Time: ${1 / fps}`)
|
|
119
|
+
for (let fi = 0; fi < frames; fi++) {
|
|
120
|
+
const time = fi / fps
|
|
121
|
+
const row: string[] = []
|
|
122
|
+
if (wrapper) row.push("0.0 0.0 0.0 0.0 0.0 0.0")
|
|
123
|
+
order.forEach((n, i) => {
|
|
124
|
+
const tr = tracks.get(n)
|
|
125
|
+
if (i === 0) {
|
|
126
|
+
const p = tr?.pos ? sample(tr.pos.t, tr.pos.v, 3, time, mixV) : Array.from(n.getTranslation())
|
|
127
|
+
row.push(f(p[0] / U), f(p[1] / U), f(p[2] / U))
|
|
128
|
+
}
|
|
129
|
+
const q = (tr?.rot ? sample(tr.rot.t, tr.rot.v, 4, time, mixQ) : Array.from(n.getRotation())) as Quat
|
|
130
|
+
const [ z, y, x ] = eulerZYX(q)
|
|
131
|
+
row.push(f(z), f(y), f(x))
|
|
132
|
+
})
|
|
133
|
+
lines.push(row.join(" "))
|
|
134
|
+
}
|
|
135
|
+
return { text: lines.join("\n") + "\n", report: { clip: anim.getName(), joints: order.length, frames, fps } }
|
|
136
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// BVH → glTF document: a skeleton-only rig (nodes + a skin with inverse binds, no mesh) carrying the
|
|
2
|
+
// file's motion as one animation clip — what `retarget --from <clip>.bvh` and `convert <clip>.bvh`
|
|
3
|
+
// read. Written for Kimodo's SOMA export (`kimodo_gen --bvh --bvh_standard_tpose` / `kimodo_convert
|
|
4
|
+
// … .bvh --bvh_standard_tpose`: centimetres, Y-up, a zero `Root` above `Hips`, ZYX Euler channels,
|
|
5
|
+
// the rest pose = the standard T-pose), but the parser is plain BVH.
|
|
6
|
+
//
|
|
7
|
+
// Conventions taken: OFFSET = the joint's rest translation; a joint's own position channels REPLACE
|
|
8
|
+
// its offset (bvhio / Kimodo: the T-pose file's Hips channels equal its offset); rotation channels
|
|
9
|
+
// compose in the listed order (Zrotation Yrotation Xrotation → R = Rz·Ry·Rx); End Sites become
|
|
10
|
+
// `<parent>_End` leaves outside the skin. A zero-offset root with a single child (Kimodo's `Root`
|
|
11
|
+
// above `Hips`) is folded into that child unless `keepRoot`.
|
|
12
|
+
|
|
13
|
+
import { Document } from "@gltf-transform/core"
|
|
14
|
+
|
|
15
|
+
type Quat = [number, number, number, number]
|
|
16
|
+
type Vec3 = [number, number, number]
|
|
17
|
+
type Joint = { name: string, parent: number, offset: Vec3, channels: string[], end: boolean }
|
|
18
|
+
|
|
19
|
+
export type BvhOptions = {
|
|
20
|
+
/** the clip's name (default: the file's stem) */
|
|
21
|
+
name?: string
|
|
22
|
+
/** keep a zero-offset single-child root joint instead of folding it into its child */
|
|
23
|
+
keepRoot?: boolean
|
|
24
|
+
/** the file's length unit in metres (default 0.01: centimetres, the mocap convention) */
|
|
25
|
+
unit?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BvhReport = {
|
|
29
|
+
joints: number
|
|
30
|
+
skinJoints: number
|
|
31
|
+
folded?: string
|
|
32
|
+
frames: number
|
|
33
|
+
fps: number
|
|
34
|
+
duration: number
|
|
35
|
+
rotationTracks: number
|
|
36
|
+
translationTracks: number
|
|
37
|
+
/** the hips' height range and horizontal travel, metres */
|
|
38
|
+
hipsY: [number, number]
|
|
39
|
+
travel: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const qmul = (a: Quat, b: Quat): Quat => [
|
|
43
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
44
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
45
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
46
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
47
|
+
]
|
|
48
|
+
const qaxis = (axis: string, deg: number): Quat => {
|
|
49
|
+
const h = deg * Math.PI / 360, s = Math.sin(h), c = Math.cos(h)
|
|
50
|
+
return axis === "X" ? [ s, 0, 0, c ] : axis === "Y" ? [ 0, s, 0, c ] : [ 0, 0, s, c ]
|
|
51
|
+
}
|
|
52
|
+
const qrot = (q: Quat, v: Vec3): Vec3 => {
|
|
53
|
+
const [ x, y, z, w ] = q
|
|
54
|
+
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
|
|
55
|
+
return [ ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x ]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const parseBvh = (text: string): { joints: Joint[], frames: number, frameTime: number, rows: number[][] } => {
|
|
59
|
+
const motionAt = text.indexOf("MOTION")
|
|
60
|
+
if (motionAt < 0) throw new Error("BVH: no MOTION section")
|
|
61
|
+
const hier = text.slice(0, motionAt).replace(/\r/g, "").split(/\s+/).filter(Boolean)
|
|
62
|
+
const joints: Joint[] = []
|
|
63
|
+
const stack: number[] = []
|
|
64
|
+
let i = 0
|
|
65
|
+
const next = (): string => hier[i++]
|
|
66
|
+
while (i < hier.length) {
|
|
67
|
+
const tok = next()
|
|
68
|
+
if (tok === "HIERARCHY") continue
|
|
69
|
+
if (tok === "ROOT" || tok === "JOINT") {
|
|
70
|
+
joints.push({ name: next(), parent: stack.length ? stack[stack.length - 1] : -1, offset: [ 0, 0, 0 ], channels: [], end: false })
|
|
71
|
+
} else if (tok === "End") {
|
|
72
|
+
next() // Site
|
|
73
|
+
joints.push({ name: `${joints[stack[stack.length - 1]].name}_End`, parent: stack[stack.length - 1], offset: [ 0, 0, 0 ], channels: [], end: true })
|
|
74
|
+
} else if (tok === "{") {
|
|
75
|
+
stack.push(joints.length - 1)
|
|
76
|
+
} else if (tok === "}") {
|
|
77
|
+
stack.pop()
|
|
78
|
+
} else if (tok === "OFFSET") {
|
|
79
|
+
joints[joints.length - 1].offset = [ +next(), +next(), +next() ]
|
|
80
|
+
} else if (tok === "CHANNELS") {
|
|
81
|
+
const n = +next()
|
|
82
|
+
for (let k = 0; k < n; k++) joints[joints.length - 1].channels.push(next())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const motion = text.slice(motionAt).replace(/\r/g, "").split("\n")
|
|
86
|
+
const frames = +(motion.find((l) => l.startsWith("Frames:"))?.split(":")[1] ?? 0)
|
|
87
|
+
const frameTime = +(motion.find((l) => l.startsWith("Frame Time:"))?.split(":")[1] ?? 1 / 30)
|
|
88
|
+
const dataStart = motion.findIndex((l) => l.startsWith("Frame Time:")) + 1
|
|
89
|
+
const rows = motion.slice(dataStart).map((l) => l.trim()).filter(Boolean).slice(0, frames).map((l) => l.split(/\s+/).map(Number))
|
|
90
|
+
const channelCount = joints.reduce((n, j) => n + j.channels.length, 0)
|
|
91
|
+
if (rows.length !== frames || rows.some((r) => r.length !== channelCount)) throw new Error(`BVH: motion data mismatch — ${rows.length}/${frames} frames, ${channelCount} channels per frame expected`)
|
|
92
|
+
return { joints, frames, frameTime, rows }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const bvhToDocument = (text: string, opts: BvhOptions = {}): { doc: Document, report: BvhReport } => {
|
|
96
|
+
const { joints, frames, frameTime, rows } = parseBvh(text)
|
|
97
|
+
const U = opts.unit ?? 0.01
|
|
98
|
+
const clipName = opts.name ?? "bvh"
|
|
99
|
+
|
|
100
|
+
// per frame, per joint: local translation (m) and rotation
|
|
101
|
+
const local = rows.map((row) => {
|
|
102
|
+
let c = 0
|
|
103
|
+
return joints.map((j) => {
|
|
104
|
+
const t: Vec3 = [ j.offset[0] * U, j.offset[1] * U, j.offset[2] * U ]
|
|
105
|
+
let q: Quat = [ 0, 0, 0, 1 ]
|
|
106
|
+
let hasPos = false
|
|
107
|
+
for (const ch of j.channels) {
|
|
108
|
+
const v = row[c++]
|
|
109
|
+
if (ch === "Xposition") { t[0] = v * U; hasPos = true } else if (ch === "Yposition") { t[1] = v * U; hasPos = true } else if (ch === "Zposition") { t[2] = v * U; hasPos = true }
|
|
110
|
+
else if (ch.endsWith("rotation")) q = qmul(q, qaxis(ch[0], v))
|
|
111
|
+
}
|
|
112
|
+
return { t, q, hasPos }
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// fold a zero-offset, single-child root into its child (Kimodo's `Root` above `Hips`)
|
|
117
|
+
let dropped = -1
|
|
118
|
+
let folded: string | undefined
|
|
119
|
+
if (!opts.keepRoot && joints[0].parent === -1 && joints.filter((j) => j.parent === 0).length === 1 && joints[0].offset.every((v) => v === 0)) {
|
|
120
|
+
const child = joints.findIndex((j) => j.parent === 0)
|
|
121
|
+
for (const f of local) {
|
|
122
|
+
const r = f[0], h = f[child]
|
|
123
|
+
const t = qrot(r.q, h.t)
|
|
124
|
+
f[child] = { t: [ r.t[0] + t[0], r.t[1] + t[1], r.t[2] + t[2] ], q: qmul(r.q, h.q), hasPos: true }
|
|
125
|
+
}
|
|
126
|
+
joints[child].parent = -1
|
|
127
|
+
dropped = 0
|
|
128
|
+
folded = `${joints[0].name} → ${joints[child].name}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const doc = new Document()
|
|
132
|
+
const buffer = doc.createBuffer()
|
|
133
|
+
const scene = doc.createScene("Scene")
|
|
134
|
+
const nodes = joints.map((j, i) => i === dropped ? null : doc.createNode(j.name).setTranslation([ j.offset[0] * U, j.offset[1] * U, j.offset[2] * U ]))
|
|
135
|
+
const worldRest: Vec3[] = joints.map(() => [ 0, 0, 0 ])
|
|
136
|
+
joints.forEach((j, i) => {
|
|
137
|
+
if (i === dropped) return
|
|
138
|
+
const p = j.parent
|
|
139
|
+
const pw = p >= 0 && p !== dropped ? worldRest[p] : [ 0, 0, 0 ]
|
|
140
|
+
worldRest[i] = [ pw[0] + j.offset[0] * U, pw[1] + j.offset[1] * U, pw[2] + j.offset[2] * U ]
|
|
141
|
+
if (p >= 0 && p !== dropped) nodes[p]!.addChild(nodes[i]!); else scene.addChild(nodes[i]!)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// the skin: every non-end joint, inverse binds from the rest pose (offsets only — pure translations)
|
|
145
|
+
const armature = doc.createNode("Armature")
|
|
146
|
+
scene.addChild(armature)
|
|
147
|
+
const skin = doc.createSkin("bvh")
|
|
148
|
+
const skinJoints = joints.map((_, i) => i).filter((i) => i !== dropped && !joints[i].end)
|
|
149
|
+
const ibm = new Float32Array(skinJoints.length * 16)
|
|
150
|
+
skinJoints.forEach((ji, k) => {
|
|
151
|
+
const o = k * 16
|
|
152
|
+
ibm[o] = 1; ibm[o + 5] = 1; ibm[o + 10] = 1; ibm[o + 15] = 1
|
|
153
|
+
ibm[o + 12] = -worldRest[ji][0]; ibm[o + 13] = -worldRest[ji][1]; ibm[o + 14] = -worldRest[ji][2]
|
|
154
|
+
skin.addJoint(nodes[ji]!)
|
|
155
|
+
})
|
|
156
|
+
skin.setInverseBindMatrices(doc.createAccessor("ibm").setType("MAT4").setArray(ibm).setBuffer(buffer))
|
|
157
|
+
skin.setSkeleton(nodes[joints.findIndex((j, i) => i !== dropped && (j.parent === -1 || j.parent === dropped))]!)
|
|
158
|
+
armature.setSkin(skin)
|
|
159
|
+
|
|
160
|
+
// the clip
|
|
161
|
+
const anim = doc.createAnimation(clipName)
|
|
162
|
+
const times = new Float32Array(frames)
|
|
163
|
+
for (let f = 0; f < frames; f++) times[f] = f * frameTime
|
|
164
|
+
const timeAcc = doc.createAccessor("time").setType("SCALAR").setArray(times).setBuffer(buffer)
|
|
165
|
+
let rotationTracks = 0, translationTracks = 0
|
|
166
|
+
joints.forEach((j, i) => {
|
|
167
|
+
if (i === dropped || j.end) return
|
|
168
|
+
if (j.channels.some((c) => c.endsWith("rotation"))) {
|
|
169
|
+
const out = new Float32Array(frames * 4)
|
|
170
|
+
let prev: Quat = [ 0, 0, 0, 1 ]
|
|
171
|
+
for (let f = 0; f < frames; f++) {
|
|
172
|
+
let q = local[f][i].q
|
|
173
|
+
if (q[0] * prev[0] + q[1] * prev[1] + q[2] * prev[2] + q[3] * prev[3] < 0) q = [ -q[0], -q[1], -q[2], -q[3] ]
|
|
174
|
+
out.set(q, f * 4); prev = q
|
|
175
|
+
}
|
|
176
|
+
const s = doc.createAnimationSampler().setInput(timeAcc).setOutput(doc.createAccessor().setType("VEC4").setArray(out).setBuffer(buffer)).setInterpolation("LINEAR")
|
|
177
|
+
anim.addSampler(s).addChannel(doc.createAnimationChannel().setTargetNode(nodes[i]!).setTargetPath("rotation").setSampler(s))
|
|
178
|
+
rotationTracks++
|
|
179
|
+
}
|
|
180
|
+
if (local.some((f) => f[i].hasPos)) {
|
|
181
|
+
const out = new Float32Array(frames * 3)
|
|
182
|
+
for (let f = 0; f < frames; f++) out.set(local[f][i].t, f * 3)
|
|
183
|
+
const s = doc.createAnimationSampler().setInput(timeAcc).setOutput(doc.createAccessor().setType("VEC3").setArray(out).setBuffer(buffer)).setInterpolation("LINEAR")
|
|
184
|
+
anim.addSampler(s).addChannel(doc.createAnimationChannel().setTargetNode(nodes[i]!).setTargetPath("translation").setSampler(s))
|
|
185
|
+
translationTracks++
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const hipsI = Math.max(0, joints.findIndex((j, i) => i !== dropped && (j.parent === -1 || j.parent === dropped)))
|
|
190
|
+
const hipY = local.map((f) => f[hipsI].t[1])
|
|
191
|
+
const a = local[0][hipsI].t, b = local[frames - 1][hipsI].t
|
|
192
|
+
return {
|
|
193
|
+
doc,
|
|
194
|
+
report: {
|
|
195
|
+
joints: joints.length - (dropped >= 0 ? 1 : 0), skinJoints: skinJoints.length, folded, frames, fps: 1 / frameTime, duration: frames * frameTime,
|
|
196
|
+
rotationTracks, translationTracks, hipsY: [ Math.min(...hipY), Math.max(...hipY) ], travel: Math.hypot(b[0] - a[0], b[2] - a[2]),
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const describeBvh = (r: BvhReport): string =>
|
|
202
|
+
`${r.joints} joints (${r.skinJoints} in the skin${r.folded ? `, ${r.folded} folded` : ""}), ${r.frames} frames @ ${r.fps.toFixed(1)} fps = ${r.duration.toFixed(2)} s · ${r.rotationTracks} rotation + ${r.translationTracks} translation tracks · hips y ${r.hipsY[0].toFixed(3)}…${r.hipsY[1].toFixed(3)} m · travel ${r.travel.toFixed(2)} m`
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A command is DATA: its name, one summary line, the flags it takes, its subcommands, and `run`.
|
|
3
|
+
* The parser (parse.ts) reads the argv AGAINST that declaration — which flag takes a value, which
|
|
4
|
+
* takes several, which may stand alone — so a flag the code reads and the parser does not know
|
|
5
|
+
* cannot exist, and the help (help.ts) is rendered from the same data. The shape follows
|
|
6
|
+
* lecodes-cli's `src/cli` (this package stays standalone, so it carries its own small copy).
|
|
7
|
+
*
|
|
8
|
+
* export default defineCommand({
|
|
9
|
+
* name: "mirror",
|
|
10
|
+
* summary: "Add mirrored copies of clips (left ↔ right)",
|
|
11
|
+
* usage: "<model.glb>",
|
|
12
|
+
* flags: {
|
|
13
|
+
* clips: list("only these clips", { value: "a b …" }),
|
|
14
|
+
* suffix: str("appended to the mirrored clip's name", { value: "_M" }),
|
|
15
|
+
* o: str("output (default: in place)", { value: "out.glb" }),
|
|
16
|
+
* },
|
|
17
|
+
* run: async ({ args, flags }) => { … flags.clips: string[], flags.suffix: string | undefined … return 0 },
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* Flag names are their command-line spelling without the dashes (`"clip-names"`, `"o"` for `-o`);
|
|
21
|
+
* read them as `flags["clip-names"]`. Groups of flags several commands share are plain objects
|
|
22
|
+
* spread into `flags:` (see `../commands/shared.ts`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type Base = {
|
|
26
|
+
help: string
|
|
27
|
+
/** The placeholder shown in help (`<file>`, `N`). */
|
|
28
|
+
value?: string
|
|
29
|
+
/** A heading the help prints above this flag (and the ones after it, until the next heading). */
|
|
30
|
+
group?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FlagSpec =
|
|
34
|
+
| Base & { type: "string", /** value when the flag stands alone (`--split` next to `--split dir`) */ bare?: string }
|
|
35
|
+
| Base & { type: "number", bare?: number, integer?: boolean, /** must be > 0 */ positive?: boolean }
|
|
36
|
+
| Base & { type: "boolean" }
|
|
37
|
+
| Base & { type: "list" }
|
|
38
|
+
|
|
39
|
+
export type Flags = Record<string, FlagSpec>
|
|
40
|
+
|
|
41
|
+
type Opts = { value?: string, group?: string }
|
|
42
|
+
/** A string-valued flag (`-o out.glb`). `bare` lets it stand alone and names what it then reads as. */
|
|
43
|
+
export const str = (help: string, opts?: Opts & { bare?: string }): FlagSpec & { type: "string" } => ({ type: "string", help, ...opts })
|
|
44
|
+
/** A numeric flag, parsed and validated (`--scale 0.01`); `integer` / `positive` narrow it. */
|
|
45
|
+
export const num = (help: string, opts?: Opts & { bare?: number, integer?: boolean, positive?: boolean }): FlagSpec & { type: "number" } => ({ type: "number", help, ...opts })
|
|
46
|
+
/** A whole number (`--max-size 1024`). */
|
|
47
|
+
export const int = (help: string, opts?: Opts & { bare?: number, positive?: boolean }): FlagSpec & { type: "number" } => ({ type: "number", help, integer: true, ...opts })
|
|
48
|
+
/** A switch (`--force`). */
|
|
49
|
+
export const bool = (help: string, opts?: { group?: string }): FlagSpec & { type: "boolean" } => ({ type: "boolean", help, ...opts })
|
|
50
|
+
/** A flag that takes every value up to the next flag (`--clips a.fbx b.fbx`); repeats accumulate. */
|
|
51
|
+
export const list = (help: string, opts?: Opts): FlagSpec & { type: "list" } => ({ type: "list", help, ...opts })
|
|
52
|
+
|
|
53
|
+
type ValueOf<S extends FlagSpec> =
|
|
54
|
+
S extends { type: "boolean" } ? boolean
|
|
55
|
+
: S extends { type: "list" } ? string[]
|
|
56
|
+
: S extends { type: "number" } ? number | undefined
|
|
57
|
+
: string | undefined
|
|
58
|
+
|
|
59
|
+
export type FlagValues<F extends Flags> = { [K in keyof F]: ValueOf<F[K]> }
|
|
60
|
+
|
|
61
|
+
export type Context<F extends Flags = Flags> = {
|
|
62
|
+
/** Positional arguments after the command path. */
|
|
63
|
+
args: string[]
|
|
64
|
+
/** The declared flags, typed and validated. */
|
|
65
|
+
flags: FlagValues<F>
|
|
66
|
+
/** Which declared flags were on the line at all (a `bare` flag reads the same as its default). */
|
|
67
|
+
given: Set<keyof F & string>
|
|
68
|
+
/** The argv after the command path, untouched. */
|
|
69
|
+
argv: string[]
|
|
70
|
+
/** The command path that was run (`["unreal", "tex"]`). */
|
|
71
|
+
path: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Command<F extends Flags = Flags> = {
|
|
75
|
+
name: string
|
|
76
|
+
/** One line for the command list. */
|
|
77
|
+
summary: string
|
|
78
|
+
/** The positional syntax after the name: `"<model.glb>"`. Omit for none. */
|
|
79
|
+
usage?: string
|
|
80
|
+
/** Paragraphs for `<name> --help`; blank lines separate paragraphs. */
|
|
81
|
+
description?: string
|
|
82
|
+
flags?: F
|
|
83
|
+
commands?: Command<any>[]
|
|
84
|
+
examples?: string[]
|
|
85
|
+
/** What runs; resolves to the exit code. A command with subcommands and no `run` prints its help. */
|
|
86
|
+
run?: (ctx: Context<F>) => Promise<number> | number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const defineCommand = <F extends Flags>(cmd: Command<F>): Command<F> => cmd
|
|
90
|
+
|
|
91
|
+
/** A failure with its exit code: 1 = the work failed, 2 = the command line is wrong. The runner prints `✗ message`. */
|
|
92
|
+
export class CliError extends Error {
|
|
93
|
+
constructor(message: string, readonly code = 1, /** print the command's help page after the message */ readonly showHelp = false) { super(message) }
|
|
94
|
+
}
|
|
95
|
+
/** The command line is wrong (exit 2). */
|
|
96
|
+
export const usageError = (message: string): CliError => new CliError(message, 2)
|
|
97
|
+
/** Something the command cannot start without is missing (exit 2, followed by the command's help). */
|
|
98
|
+
export const missing = (what: string): CliError => new CliError(`missing ${what}`, 2, true)
|
|
99
|
+
/** The input path does not exist (exit 1). */
|
|
100
|
+
export const notFound = (path: string): CliError => new CliError(`not found: ${path}`)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Command, FlagSpec } from "./command"
|
|
2
|
+
import { spell } from "./parse"
|
|
3
|
+
|
|
4
|
+
// Two levels, both rendered from the command declarations: the command list (bare call / --help at
|
|
5
|
+
// the top) and one command's page (`<command> --help`, and what a usage error prints).
|
|
6
|
+
|
|
7
|
+
const WIDTH = 100
|
|
8
|
+
const wrap = (text: string, indent: number): string[] => {
|
|
9
|
+
const room = Math.max(30, WIDTH - indent)
|
|
10
|
+
const out: string[] = []
|
|
11
|
+
for (const para of text.split("\n")) {
|
|
12
|
+
let line = ""
|
|
13
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
14
|
+
if (line && line.length + 1 + word.length > room) { out.push(line); line = word } else line = line ? `${line} ${word}` : word
|
|
15
|
+
}
|
|
16
|
+
out.push(line)
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const flagLabel = (name: string, s: FlagSpec): string => {
|
|
22
|
+
if (s.type === "boolean") return spell(name)
|
|
23
|
+
const value = s.value ?? (s.type === "number" ? "N" : s.type === "list" ? "a b …" : "<value>")
|
|
24
|
+
return `${spell(name)} ${"bare" in s && s.bare !== undefined ? `[${value}]` : value}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rows = (pairs: [string, string][], indent: number, column: number): string[] => {
|
|
28
|
+
const out: string[] = []
|
|
29
|
+
const pad = " ".repeat(indent)
|
|
30
|
+
for (const [ label, help ] of pairs) {
|
|
31
|
+
const lines = wrap(help, indent + column)
|
|
32
|
+
if (label.length < column - 1) out.push(`${pad}${label.padEnd(column)}${lines[0] ?? ""}`)
|
|
33
|
+
else { out.push(`${pad}${label}`); out.push(`${pad}${" ".repeat(column)}${lines[0] ?? ""}`) }
|
|
34
|
+
for (const l of lines.slice(1)) out.push(`${pad}${" ".repeat(column)}${l}`)
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const PROGRAM = "lecodes assets"
|
|
40
|
+
|
|
41
|
+
/** The command list. */
|
|
42
|
+
export const renderRoot = (title: string, commands: Command<any>[]): string => [
|
|
43
|
+
title,
|
|
44
|
+
"",
|
|
45
|
+
`Usage: ${PROGRAM} <command> [args…] (${PROGRAM} <command> --help for its flags)`,
|
|
46
|
+
"",
|
|
47
|
+
...rows(commands.map((c) => [ c.name, c.summary ]), 2, 16),
|
|
48
|
+
].join("\n")
|
|
49
|
+
|
|
50
|
+
/** One command's page: syntax, description, subcommands, flags by group, examples. */
|
|
51
|
+
export const renderCommand = (cmd: Command<any>, path: string[]): string => {
|
|
52
|
+
const out: string[] = []
|
|
53
|
+
const full = `${PROGRAM} ${path.join(" ")}`
|
|
54
|
+
out.push(`${full}${cmd.commands?.length ? " <command>" : ""}${cmd.usage ? ` ${cmd.usage}` : ""}${cmd.flags && Object.keys(cmd.flags).length ? " [flags]" : ""}`)
|
|
55
|
+
out.push("", ...wrap(cmd.summary, 2).map((l) => ` ${l}`))
|
|
56
|
+
if (cmd.description) for (const para of cmd.description.trim().split(/\n\s*\n/)) out.push("", ...wrap(para.replace(/\s*\n\s*/g, " "), 2).map((l) => ` ${l}`))
|
|
57
|
+
if (cmd.commands?.length) out.push("", "Commands:", ...rows(cmd.commands.map((c) => [ `${c.name}${c.usage ? ` ${c.usage}` : ""}`, c.summary ]), 2, 30))
|
|
58
|
+
const flags = Object.entries((cmd.flags ?? {}) as Record<string, FlagSpec>)
|
|
59
|
+
if (flags.length) {
|
|
60
|
+
let group: string | undefined
|
|
61
|
+
let batch: [string, string][] = []
|
|
62
|
+
const flush = (): void => { if (batch.length) out.push(...rows(batch, 2, 30)); batch = [] }
|
|
63
|
+
out.push("", "Flags:")
|
|
64
|
+
for (const [ name, s ] of flags) {
|
|
65
|
+
if (s.group !== undefined && s.group !== group) { flush(); group = s.group; out.push("", ` ${group}`) }
|
|
66
|
+
batch.push([ flagLabel(name, s), s.help ])
|
|
67
|
+
}
|
|
68
|
+
flush()
|
|
69
|
+
}
|
|
70
|
+
if (cmd.examples?.length) out.push("", "Examples:", ...cmd.examples.map((e) => ` ${e}`))
|
|
71
|
+
return out.join("\n")
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { usageError, type Flags, type FlagSpec, type FlagValues } from "./command"
|
|
2
|
+
|
|
3
|
+
// `--region -10,0,50,60` / `--scale -1`: "-" + digit is a negative number, not the next flag.
|
|
4
|
+
const isValue = (s: string): boolean => !s.startsWith("-") || /^-(\d|\.\d)/.test(s)
|
|
5
|
+
const flagName = (a: string): string => a.replace(/^--?/, "")
|
|
6
|
+
/** How a flag is spelled on the line: one letter takes one dash. */
|
|
7
|
+
export const spell = (name: string): string => (name.length === 1 ? `-${name}` : `--${name}`)
|
|
8
|
+
|
|
9
|
+
export type ParsedArgs<F extends Flags> = { args: string[], flags: FlagValues<F>, given: Set<keyof F & string>, unknown: string[] }
|
|
10
|
+
|
|
11
|
+
const readNumber = (name: string, s: FlagSpec & { type: "number" }, text: string): number => {
|
|
12
|
+
const n = Number(text)
|
|
13
|
+
if (!Number.isFinite(n)) throw usageError(`${spell(name)} needs a number, got "${text}"`)
|
|
14
|
+
if (s.integer && !Number.isInteger(n)) throw usageError(`${spell(name)} needs a whole number, got "${text}"`)
|
|
15
|
+
if (s.positive && n <= 0) throw usageError(`${spell(name)} needs a positive number, got "${text}"`)
|
|
16
|
+
return n
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read an argv against a flag declaration. Unknown flags are collected (the runner warns), never
|
|
20
|
+
* guessed at: an undeclared `--x 4` used to leave `4` behind as a positional. */
|
|
21
|
+
export const parseArgs = <F extends Flags>(spec: F, argv: string[]): ParsedArgs<F> => {
|
|
22
|
+
const args: string[] = []
|
|
23
|
+
const unknown: string[] = []
|
|
24
|
+
const given = new Set<string>()
|
|
25
|
+
const out: Record<string, unknown> = {}
|
|
26
|
+
for (const [ name, s ] of Object.entries(spec)) out[name] = s.type === "boolean" ? false : s.type === "list" ? [] : undefined
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i]
|
|
29
|
+
if (isValue(a)) { args.push(a); continue }
|
|
30
|
+
const name = flagName(a)
|
|
31
|
+
const s = spec[name]
|
|
32
|
+
if (!s || spell(name) !== a) { unknown.push(a); continue }
|
|
33
|
+
given.add(name)
|
|
34
|
+
if (s.type === "boolean") { out[name] = true; continue }
|
|
35
|
+
if (s.type === "list") {
|
|
36
|
+
const values = out[name] as string[]
|
|
37
|
+
while (i + 1 < argv.length && isValue(argv[i + 1])) values.push(argv[++i])
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
const next = i + 1 < argv.length && isValue(argv[i + 1]) ? argv[++i] : undefined
|
|
41
|
+
if (next === undefined) {
|
|
42
|
+
if (s.bare === undefined) throw usageError(`${a} needs a value${s.value ? ` (${s.value})` : ""}`)
|
|
43
|
+
out[name] = s.bare
|
|
44
|
+
} else out[name] = s.type === "number" ? readNumber(name, s, next) : next
|
|
45
|
+
}
|
|
46
|
+
return { args, flags: out as FlagValues<F>, given: given as Set<keyof F & string>, unknown }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The declared flag closest to a misspelled one (edit distance ≤ 2), for the warning. */
|
|
50
|
+
export const nearestFlag = (spec: Flags, typed: string): string | undefined => {
|
|
51
|
+
const want = flagName(typed)
|
|
52
|
+
let best: string | undefined, bd = 3
|
|
53
|
+
for (const name of Object.keys(spec)) {
|
|
54
|
+
const d = distance(want, name)
|
|
55
|
+
if (d < bd) { bd = d; best = name }
|
|
56
|
+
}
|
|
57
|
+
return best ? spell(best) : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const distance = (a: string, b: string): number => {
|
|
61
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
62
|
+
for (let i = 1; i <= a.length; i++) {
|
|
63
|
+
let prev = row[0]
|
|
64
|
+
row[0] = i
|
|
65
|
+
for (let j = 1; j <= b.length; j++) {
|
|
66
|
+
const cur = row[j]
|
|
67
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
68
|
+
prev = cur
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return row[b.length]
|
|
72
|
+
}
|