lecodes-cli 0.17.0 → 0.17.1

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/index.js CHANGED
@@ -120,7 +120,7 @@ var useColor, paint = (code, s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s, c,
120
120
  if (!answer)
121
121
  return fallback;
122
122
  return answer === "y" || answer === "yes";
123
- }, parseArgs = (argv) => {
123
+ }, isNegativeNumber = (s) => /^-(\d|\.\d)/.test(s), isValue = (s) => !s.startsWith("-") || isNegativeNumber(s), parseArgs = (argv) => {
124
124
  const out = { _: [], flags: {} };
125
125
  for (let i = 0;i < argv.length; i++) {
126
126
  const arg = argv[i];
@@ -132,15 +132,15 @@ var useColor, paint = (code, s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s, c,
132
132
  }
133
133
  const key = arg.slice(2);
134
134
  const next = argv[i + 1];
135
- if (next !== undefined && !next.startsWith("-")) {
135
+ if (next !== undefined && isValue(next)) {
136
136
  out.flags[key] = next;
137
137
  i++;
138
138
  } else
139
139
  out.flags[key] = true;
140
- } else if (arg.startsWith("-") && arg.length > 1) {
140
+ } else if (arg.startsWith("-") && arg.length > 1 && !isNegativeNumber(arg)) {
141
141
  const key = arg.slice(1);
142
142
  const next = argv[i + 1];
143
- if (next !== undefined && !next.startsWith("-")) {
143
+ if (next !== undefined && isValue(next)) {
144
144
  out.flags[key] = next;
145
145
  i++;
146
146
  } else
@@ -388,6 +388,34 @@ design/comments/
388
388
  const file = join3(root, ".lecodesignore");
389
389
  if (!existsSync2(file))
390
390
  writeFileSync3(file, DEFAULT_IGNORE_FILE);
391
+ }, DEFAULT_GITIGNORE = `# LeCodes per-checkout state: generated IDE types + this machine's sync cursor
392
+ # (.lecodes/manifest.json). Regenerate with \`lecodes types\` / re-attach with \`lecodes link\`.
393
+ .lecodes/
394
+
395
+ # Render / preview outputs and test failure frames
396
+ screenshot.png
397
+ shots/
398
+ tests/.artifacts/
399
+
400
+ node_modules/
401
+ `, ensureGitignore = (root) => {
402
+ const file = join3(root, ".gitignore");
403
+ if (!existsSync2(file)) {
404
+ writeFileSync3(file, DEFAULT_GITIGNORE);
405
+ return "created";
406
+ }
407
+ const text = readFileSync3(file, "utf8");
408
+ const covered = text.split(/\r?\n/).some((l) => /^\/?\.lecodes\/?\s*$/.test(l.trim()));
409
+ if (covered)
410
+ return null;
411
+ const sep = text.length === 0 || text.endsWith(`
412
+ `) ? "" : `
413
+ `;
414
+ writeFileSync3(file, `${text}${sep}
415
+ # LeCodes per-checkout state (generated types + sync cursor) — not source
416
+ .lecodes/
417
+ `);
418
+ return "updated";
391
419
  };
392
420
  var init_ignore = __esm(() => {
393
421
  BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store", ".artifacts/"];
@@ -84194,8 +84222,8 @@ ${lanes.join(`
84194
84222
  if (node === undefined || node.parent.kind !== 307 || !isInternalModuleImportEqualsDeclaration(node)) {
84195
84223
  return false;
84196
84224
  }
84197
- const isValue = isAliasResolvedToValue(getSymbolOfDeclaration(node));
84198
- return isValue && node.moduleReference && !nodeIsMissing(node.moduleReference);
84225
+ const isValue2 = isAliasResolvedToValue(getSymbolOfDeclaration(node));
84226
+ return isValue2 && node.moduleReference && !nodeIsMissing(node.moduleReference);
84199
84227
  }
84200
84228
  function isAliasResolvedToValue(symbol, excludeTypeOnlyValues) {
84201
84229
  if (!symbol) {
@@ -215816,7 +215844,7 @@ var require_main = __commonJS((exports, module) => {
215816
215844
  // package.json
215817
215845
  var package_default = {
215818
215846
  name: "lecodes-cli",
215819
- version: "0.17.0",
215847
+ version: "0.17.1",
215820
215848
  dependencies: {
215821
215849
  "@letary/chisel": "^0.8.0",
215822
215850
  jimp: "^1.6.1"
@@ -215832,7 +215860,7 @@ var package_default = {
215832
215860
  },
215833
215861
  peerDependencies: {
215834
215862
  "lecodes-design": "^0.8.1",
215835
- "lecodes-renderer": "^0.8.5",
215863
+ "lecodes-renderer": "^0.8.6",
215836
215864
  "lecodes-3d-editor": "^0.3.0",
215837
215865
  "lecodes-assets": "^0.1.0"
215838
215866
  },
@@ -220089,6 +220117,7 @@ var clone3 = async (args) => {
220089
220117
  writeTsconfig(root);
220090
220118
  }
220091
220119
  writeDefaultIgnore(root);
220120
+ ensureGitignore(root);
220092
220121
  const commits = await getCommits(apiUrl, token, uuid);
220093
220122
  writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
220094
220123
  success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
@@ -220183,7 +220212,8 @@ channel for game state that has no UI.
220183
220212
 
220184
220213
  ## SDK documentation
220185
220214
 
220186
- \`.lecodes/types/\` is the exact API surface (signatures only). For semantics and examples,
220215
+ \`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
220216
+ \`lecodes types\` if it's missing, never commit \`.lecodes/\`). For semantics and examples,
220187
220217
  fetch the docs — they are served as raw markdown for AI tools:
220188
220218
 
220189
220219
  - https://le.codes/llms.txt — the docs map; fetch it first to find the right page
@@ -221261,6 +221291,9 @@ lecodes app open # open it in Xcode, build & run
221261
221291
  - \`assets/\` — images/fonts the app references (created on demand)
221262
221292
  - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
221263
221293
  They come from the SDK bundled in the CLI; \`lecodes types\` refreshes them from the platform.
221294
+ - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
221295
+ sync cursor once linked). After a fresh git clone: \`lecodes types\`, then
221296
+ \`lecodes link --project <uuid>\` to re-attach.
221264
221297
  - \`.lecodesignore\` — files a future \`lecodes push\` would skip
221265
221298
 
221266
221299
  Full command list: \`lecodes --help\`.
@@ -221291,6 +221324,14 @@ var init2 = async (args) => {
221291
221324
  write(".claude/settings.json", CLAUDE_SETTINGS);
221292
221325
  write("tests/counter.flow.json", STARTER_FLOW);
221293
221326
  write(".lecodesignore", DEFAULT_IGNORE_FILE);
221327
+ const gi = ensureGitignore(root);
221328
+ if (gi === "created") {
221329
+ log(`${c.green("created")} .gitignore`);
221330
+ created++;
221331
+ } else if (gi === "updated")
221332
+ log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`);
221333
+ else
221334
+ note("kept .gitignore");
221294
221335
  const wroteTypes = materializeTypesLocal(root);
221295
221336
  writeTsconfig(root);
221296
221337
  log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`);
@@ -221542,6 +221583,8 @@ var link = async (args) => {
221542
221583
  log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`);
221543
221584
  }
221544
221585
  writeDefaultIgnore(root);
221586
+ if (ensureGitignore(root))
221587
+ log(`${c.green("written")} .gitignore ${c.dim("(.lecodes/ is per-checkout state)")}`);
221545
221588
  if (flagBool(args, "push")) {
221546
221589
  log("");
221547
221590
  if (root !== process.cwd())
@@ -223503,7 +223546,22 @@ ${c.bold("Commands:")}
223503
223546
  FBX → LeCodes-ready GLB: meters/Y-up, textures embedded, clips from
223504
223547
  other FBX files merged by bone name (Mixamo: one FBX per animation);
223505
223548
  --fix/--ktx2 run the doctor passes (needs 'lecodes-assets')
223506
- assets probe <file.fbx|.glb> Skeleton, clips, meshes, materials, textures, bind sanity
223549
+ Unity .mat files next to the FBX are read for the real textures
223550
+ (metallic/roughness/ORM/emission packed the glTF way); --no-unity
223551
+ keeps the FBX references; --unity-index <unity-index.txt> resolves GUIDs
223552
+ --list-nodes / --keep <node> … / --split [dir]: carve one variant out of a
223553
+ multi-variant prop file, re-origined on the ground (or one GLB per node)
223554
+ --merge-skins b.fbx …: modular character pieces onto ONE skeleton
223555
+ assets unpack <pkg.unitypackage> [-o dir] [--only re …] [--list]
223556
+ Stream a .unitypackage to a folder: writes unity-index.txt (GUID → path,
223557
+ what convert resolves textures with) and the matching assets + .meta
223558
+ assets scene <map.unity> [-o level.json] [--filter re] [--group g] [--region x0,z0,x1,z1]
223559
+ Unity scene → placed prefabs / mesh objects with world TRS in LeCodes'
223560
+ frame + a prefab table (FBX + --keep node per prefab)
223561
+ assets probe <file.fbx|.glb> [--filmstrip [out.png]]
223562
+ Skeleton, clips (+ root motion / first-frame-vs-rest per clip), meshes,
223563
+ materials, textures, bind sanity, duplicate names; --filmstrip renders
223564
+ a CPU-skinned PNG strip per clip (no GPU)
223507
223565
  assets doctor <in.glb|dir> [--fix] [--ktx2] [--dump]
223508
223566
  Analyze / fix a GLB against engine limits (bone cap 256, texture
223509
223567
  formats/size, KTX2 via toktx)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "dependencies": {
5
5
  "@letary/chisel": "^0.8.0",
6
6
  "jimp": "^1.6.1"
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "peerDependencies": {
18
18
  "lecodes-design": "^0.8.1",
19
- "lecodes-renderer": "^0.8.5",
19
+ "lecodes-renderer": "^0.8.6",
20
20
  "lecodes-3d-editor": "^0.3.0",
21
21
  "lecodes-assets": "^0.1.0"
22
22
  },
@@ -1,170 +1,174 @@
1
- // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
- // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
- // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
- // walked up through parents — one instance per end effector, any Node hierarchy works.
5
- //
6
- // const foot = hero.bone('LeftFoot')!
7
- // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
- // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
- // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
- //
11
- // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
- // slot in later behind the same API.
13
-
14
- import { Aspect } from "../core/Aspect"
15
- import { Vec3, type Vec3Like } from "../math/vec"
16
- import { Quat } from "../math/quat"
17
- import type { Node } from "./Node"
18
-
19
- type Target = Node | Vec3Like
20
-
21
- const targetPos = (t: Target | undefined): Vec3 | null => {
22
- if (t === undefined || t === null) return null
23
- const n = t as Node
24
- if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
- return new Vec3(t as Vec3Like)
26
- }
27
-
28
- /** World rotation of a node (parent chain composed natively). */
29
- const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
-
31
- /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
- const setWorldRot = (n: Node, q: Quat): void => {
33
- const p = n.parent
34
- n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
- }
36
-
37
- const clampAngle = (q: Quat, maxRad: number): Quat => {
38
- // angle of a unit quaternion = 2·acos(|w|)
39
- const w = Math.min(1, Math.abs(q.w))
40
- const ang = 2 * Math.acos(w)
41
- if (ang <= maxRad || ang < 1e-6) return q
42
- const s = Math.sqrt(1 - w * w)
43
- const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
- return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
- }
46
-
47
- /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
- * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
- * (knee/elbow) — a Node or world point. */
50
- class TwoBone extends Aspect<"ik", Node> {
51
- static readonly aspect = "ik"
52
-
53
- /** Where the end bone should be (Node or world position). */
54
- target?: Target
55
- /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
- pole?: Target
57
- /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
58
- weight = 1
59
- /** Solve every frame (default). Set false to drive `solve()` yourself. */
60
- enabled = true
61
-
62
- update(): void {
63
- if (this.enabled) this.solve()
64
- }
65
-
66
- /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
67
- solve(): void {
68
- const t = targetPos(this.target)
69
- if (!t || this.weight <= 0) return
70
- const end = this.node
71
- const mid = end.parent
72
- const root = mid?.parent
73
- if (!mid || !root) return
74
-
75
- const w = Math.min(1, this.weight)
76
- const midLocal0 = mid.quaternion
77
- const rootLocal0 = root.quaternion
78
-
79
- const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
80
- const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
81
- if (l1 < 1e-6 || l2 < 1e-6) return
82
- const eps = 1e-4
83
- const toT = t.sub(a)
84
- const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
85
-
86
- // 1. bend the mid joint to the angle the target distance demands (law of cosines)
87
- const ba = a.sub(b), bc = c.sub(b)
88
- const cur = ba.angle(bc)
89
- const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
90
- let axis = bc.cross(ba)
91
- if (axis.lengthSq() < 1e-10) {
92
- // straight limb: bend toward the pole (or any perpendicular)
93
- const p = targetPos(this.pole)
94
- const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
95
- axis = ba.cross(ref)
96
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
97
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
98
- }
99
- axis = axis.normalize()
100
- // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
101
- const midWorld = worldRot(mid)
102
- setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
103
-
104
- // 2. swing the root so the end lands on the target direction
105
- const c2 = end.worldPosition
106
- const rootWorld = worldRot(root)
107
- setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
108
-
109
- // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
110
- const p = targetPos(this.pole)
111
- if (p) {
112
- const dir = toT.normalize()
113
- const b2 = mid.worldPosition
114
- const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
115
- const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
116
- if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
117
- setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
118
- }
119
- }
120
-
121
- // 4. weight: blend from the animated pose to the solved one
122
- if (w < 1) {
123
- root.quaternion = rootLocal0.slerp(root.quaternion, w)
124
- mid.quaternion = midLocal0.slerp(mid.quaternion, w)
125
- }
126
- }
127
- }
128
-
129
- /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
130
- * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
131
- * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
132
- * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
133
- class LookAt extends Aspect<"lookAt", Node> {
134
- static readonly aspect = "lookAt"
135
-
136
- target?: Target
137
- /** The bone's local axis that should point at the target. */
138
- axis: Vec3Like = [ 0, 0, -1 ]
139
- /** Max deflection from the animated direction, in degrees (default 80). */
140
- limit = 80
141
- /** 0–1 contribution. */
142
- weight = 1
143
- enabled = true
144
-
145
- update(): void {
146
- if (this.enabled) this.solve()
147
- }
148
-
149
- solve(): void {
150
- const t = targetPos(this.target)
151
- if (!t || this.weight <= 0) return
152
- const bone = this.node
153
- const w = Math.min(1, this.weight)
154
- const local0 = bone.quaternion
155
- const from = bone.worldPosition
156
- const dir = t.sub(from)
157
- if (dir.lengthSq() < 1e-10) return
158
- const rot = worldRot(bone)
159
- const current = new Vec3(this.axis).rotate(rot)
160
- let delta = Quat.fromTo(current, dir)
161
- delta = clampAngle(delta, (this.limit * Math.PI) / 180)
162
- setWorldRot(bone, delta.mul(rot))
163
- if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
164
- }
165
- }
166
-
167
- /** Inverse kinematics aspects — attach to bones (see file header). */
168
- export const IK = { TwoBone, LookAt }
169
- export type IKTwoBone = TwoBone
170
- export type IKLookAt = LookAt
1
+ // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
+ // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
+ // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
+ // walked up through parents — one instance per end effector, any Node hierarchy works.
5
+ //
6
+ // const foot = hero.bone('LeftFoot')!
7
+ // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
+ // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
+ // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
+ //
11
+ // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
+ // slot in later behind the same API.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import { Vec3, type Vec3Like } from "../math/vec"
16
+ import { Quat } from "../math/quat"
17
+ import type { Node } from "./Node"
18
+
19
+ type Target = Node | Vec3Like
20
+
21
+ const targetPos = (t: Target | undefined): Vec3 | null => {
22
+ if (t === undefined || t === null) return null
23
+ const n = t as Node
24
+ if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
+ return new Vec3(t as Vec3Like)
26
+ }
27
+
28
+ /** World rotation of a node (parent chain composed natively). */
29
+ const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
+
31
+ /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
+ const setWorldRot = (n: Node, q: Quat): void => {
33
+ const p = n.parent
34
+ n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
+ }
36
+
37
+ const clampAngle = (q: Quat, maxRad: number): Quat => {
38
+ // angle of a unit quaternion = 2·acos(|w|)
39
+ const w = Math.min(1, Math.abs(q.w))
40
+ const ang = 2 * Math.acos(w)
41
+ if (ang <= maxRad || ang < 1e-6) return q
42
+ const s = Math.sqrt(1 - w * w)
43
+ const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
+ return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
+ }
46
+
47
+ /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
+ * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
+ * (knee/elbow) — a Node or world point. */
50
+ class TwoBone extends Aspect<"ik", Node> {
51
+ static readonly aspect = "ik"
52
+
53
+ /** Where the end bone should be (Node or world position). */
54
+ target?: Target
55
+ /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
+ pole?: Target
57
+ /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
58
+ weight = 1
59
+ /** Solve every frame (default). Set false to drive `solve()` yourself. */
60
+ enabled = true
61
+
62
+ update(): void {
63
+ if (this.enabled) this.solve()
64
+ }
65
+
66
+ /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
67
+ solve(): void {
68
+ const t = targetPos(this.target)
69
+ if (!t || this.weight <= 0) return
70
+ const end = this.node
71
+ const mid = end.parent
72
+ const root = mid?.parent
73
+ if (!mid || !root) return
74
+
75
+ const w = Math.min(1, this.weight)
76
+ const midLocal0 = mid.quaternion
77
+ const rootLocal0 = root.quaternion
78
+
79
+ const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
80
+ const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
81
+ if (l1 < 1e-6 || l2 < 1e-6) return
82
+ const eps = 1e-4
83
+ const toT = t.sub(a)
84
+ const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
85
+
86
+ // 1. bend the mid joint to the angle the target distance demands (law of cosines)
87
+ const ba = a.sub(b), bc = c.sub(b)
88
+ const cur = ba.angle(bc)
89
+ const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
90
+ // a positive rotation about ba × bc moves `bc` AWAY from `ba`, i.e. opens the joint — so rotating the
91
+ // mid bone by (want − cur) about it opens/closes exactly as needed. (bc × ba has the opposite sense:
92
+ // with it every already-bent limb — which is every animated limb — bends the wrong way; the straight
93
+ // case below picks its axis as ba × ref, the same sense as ba × bc for a limb barely bent toward ref.)
94
+ let axis = ba.cross(bc)
95
+ if (axis.lengthSq() < 1e-10) {
96
+ // straight limb: bend toward the pole (or any perpendicular)
97
+ const p = targetPos(this.pole)
98
+ const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
99
+ axis = ba.cross(ref)
100
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
101
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
102
+ }
103
+ axis = axis.normalize()
104
+ // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
105
+ const midWorld = worldRot(mid)
106
+ setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
107
+
108
+ // 2. swing the root so the end lands on the target direction
109
+ const c2 = end.worldPosition
110
+ const rootWorld = worldRot(root)
111
+ setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
112
+
113
+ // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
114
+ const p = targetPos(this.pole)
115
+ if (p) {
116
+ const dir = toT.normalize()
117
+ const b2 = mid.worldPosition
118
+ const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
119
+ const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
120
+ if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
121
+ setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
122
+ }
123
+ }
124
+
125
+ // 4. weight: blend from the animated pose to the solved one
126
+ if (w < 1) {
127
+ root.quaternion = rootLocal0.slerp(root.quaternion, w)
128
+ mid.quaternion = midLocal0.slerp(mid.quaternion, w)
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
134
+ * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
135
+ * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
136
+ * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
137
+ class LookAt extends Aspect<"lookAt", Node> {
138
+ static readonly aspect = "lookAt"
139
+
140
+ target?: Target
141
+ /** The bone's local axis that should point at the target. */
142
+ axis: Vec3Like = [ 0, 0, -1 ]
143
+ /** Max deflection from the animated direction, in degrees (default 80). */
144
+ limit = 80
145
+ /** 0–1 contribution. */
146
+ weight = 1
147
+ enabled = true
148
+
149
+ update(): void {
150
+ if (this.enabled) this.solve()
151
+ }
152
+
153
+ solve(): void {
154
+ const t = targetPos(this.target)
155
+ if (!t || this.weight <= 0) return
156
+ const bone = this.node
157
+ const w = Math.min(1, this.weight)
158
+ const local0 = bone.quaternion
159
+ const from = bone.worldPosition
160
+ const dir = t.sub(from)
161
+ if (dir.lengthSq() < 1e-10) return
162
+ const rot = worldRot(bone)
163
+ const current = new Vec3(this.axis).rotate(rot)
164
+ let delta = Quat.fromTo(current, dir)
165
+ delta = clampAngle(delta, (this.limit * Math.PI) / 180)
166
+ setWorldRot(bone, delta.mul(rot))
167
+ if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
168
+ }
169
+ }
170
+
171
+ /** Inverse kinematics aspects — attach to bones (see file header). */
172
+ export const IK = { TwoBone, LookAt }
173
+ export type IKTwoBone = TwoBone
174
+ export type IKLookAt = LookAt