@forgeax/engine-skinning 0.1.2

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 (36) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +24 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/binding.unit.test.d.ts +2 -0
  5. package/dist/__tests__/binding.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  7. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/plugin.integration.test.d.ts +2 -0
  9. package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/skin-error-code-owner.test-d.d.ts +27 -0
  11. package/dist/__tests__/skin-error-code-owner.test-d.d.ts.map +1 -0
  12. package/dist/assets/skin-decoder.d.ts +4 -0
  13. package/dist/assets/skin-decoder.d.ts.map +1 -0
  14. package/dist/errors.d.ts +232 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.mjs +375 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/dist/plugin.d.ts +4 -0
  21. package/dist/plugin.d.ts.map +1 -0
  22. package/dist/resolve-skin-joints.d.ts +10 -0
  23. package/dist/resolve-skin-joints.d.ts.map +1 -0
  24. package/dist/skin.d.ts +5 -0
  25. package/dist/skin.d.ts.map +1 -0
  26. package/package.json +57 -0
  27. package/src/__tests__/binding.unit.test.ts +44 -0
  28. package/src/__tests__/errors.unit.test.ts +13 -0
  29. package/src/__tests__/plugin.integration.test.ts +23 -0
  30. package/src/__tests__/skin-error-code-owner.test-d.ts +91 -0
  31. package/src/assets/skin-decoder.ts +69 -0
  32. package/src/errors.ts +356 -0
  33. package/src/index.ts +15 -0
  34. package/src/plugin.ts +25 -0
  35. package/src/resolve-skin-joints.ts +24 -0
  36. package/src/skin.ts +68 -0
package/src/errors.ts ADDED
@@ -0,0 +1,356 @@
1
+ // @forgeax/engine-runtime -- skin cluster error classes.
2
+ //
3
+ // feat-20260704-runtime-tier1-decomposition M2 / w8 (D-3): skin / skeleton
4
+ // animation cluster -- joint count / despawn / path / coexistence and
5
+ // extract-stage binding failures. Palette/material/GPU failures stay in the
6
+ // render error union because render owns the emitting frame stages. Binding
7
+ // class names, .code literals, and .detail shapes are preserved byte-for-byte
8
+ // (OOS-4).
9
+ //
10
+ // SkinExtractErrorCode (the 3-member extract-stage subset union) is kept as a
11
+ // named export and folded into SkinErrorCode, preserving the pre-existing
12
+ // public symbol (OOS-4).
13
+
14
+ // ── SkinExtractErrorCode subset union ───────────────────────────────────────
15
+
16
+ /**
17
+ * feat-20260612-skin-palette-per-frame-upload M2 / m2-5 subset union.
18
+ *
19
+ * Covers the three new fail-fast extract-stage errors that fire from
20
+ * `render-system-extract.ts` `hasSkin` segment when the per-frame palette
21
+ * upload pipeline cannot resolve a slice for an entity. Single-entity
22
+ * `continue` semantics: the entity is skipped, sibling entities in the
23
+ * same frame keep extracting (plan-strategy D-5).
24
+ *
25
+ * | code | class | trigger |
26
+ * |:--|:--|:--|
27
+ * | `'skeleton-resolve-failed'` | `SkeletonResolveFailedError` | `assets.get<SkeletonAsset>(skin.skeleton)` returns null/undefined |
28
+ * | `'joint-count-mismatch'` | `JointCountMismatchError` | `Skin.joints.length !== SkeletonAsset.jointCount` |
29
+ * | `'joint-entity-dangling'` | `JointEntityDanglingError` | `Skin.joints[i]` Entity is despawned (Transform.world view undefined) |
30
+ *
31
+ * AI users discriminate via `switch (err.code)` over `RuntimeErrorCode`;
32
+ * each member narrows to its `*Error` class with structured `.detail`.
33
+ *
34
+ * NOTE: distinct from the pre-existing `'skin-joint-despawned'` /
35
+ * `'skin-joint-path-unresolved'` / `'skin-joint-count-exceeded'`
36
+ * (advanceAnimationPlayer + post-spawn jointPath resolution); plan-strategy
37
+ * D-4 forbids reusing those codes for the new extract-stage triggers.
38
+ */
39
+ export type SkinExtractErrorCode =
40
+ | 'skeleton-resolve-failed'
41
+ | 'joint-count-mismatch'
42
+ | 'joint-entity-dangling';
43
+
44
+ // ── SkinJointCountExceededError ────────────────────────────────────────────
45
+
46
+ /**
47
+ * Detail for `RuntimeErrorCode 'skin-joint-count-exceeded'`.
48
+ *
49
+ * Emitted when a glTF skin has more than MAX_JOINTS (256) joints.
50
+ */
51
+ export interface SkinJointCountExceededDetail {
52
+ readonly jointCount: number;
53
+ readonly max: number;
54
+ }
55
+
56
+ /**
57
+ * Structured error for skin joint count exceeding the engine cap.
58
+ *
59
+ * Emitted during skin import/validation. Four-field surface:
60
+ * - `.code = 'skin-joint-count-exceeded'`
61
+ * - `.expected` — max allowed (256)
62
+ * - `.hint` — reduce joint count in the source asset
63
+ * - `.detail = { jointCount, max }` — actual vs limit
64
+ */
65
+ export class SkinJointCountExceededError extends Error {
66
+ readonly code = 'skin-joint-count-exceeded' as const;
67
+ readonly expected: string;
68
+ readonly hint: string;
69
+ readonly detail: SkinJointCountExceededDetail;
70
+
71
+ constructor(jointCount: number, max = 256) {
72
+ const expected = `jointCount <= ${max}`;
73
+ const hint = `skin has ${jointCount} joints (max ${max}); reduce joint count in the source glTF asset (OOS-skin-many-joints)`;
74
+ super(`skin joint count ${jointCount} exceeds max ${max}`);
75
+ this.name = 'SkinJointCountExceededError';
76
+ this.expected = expected;
77
+ this.hint = hint;
78
+ this.detail = { jointCount, max };
79
+ }
80
+ }
81
+
82
+ // ── SkinJointDespawnedError ─────────────────────────────────────────────
83
+
84
+ /**
85
+ * Detail for `RuntimeErrorCode 'skin-joint-despawned'`.
86
+ *
87
+ * Emitted at extract time when a Skin.joints[i] Entity has been despawned.
88
+ */
89
+ export interface SkinJointDespawnedDetail {
90
+ readonly meshEntity: number;
91
+ readonly jointIndex: number;
92
+ }
93
+
94
+ /**
95
+ * Structured error for despawned skin joint Entity.
96
+ *
97
+ * Emitted at extract time; the mesh draw is fully skipped.
98
+ * - `.code = 'skin-joint-despawned'`
99
+ * - `.expected` — all Skin.joints alive
100
+ * - `.hint` — remove the Skin component or re-spawn joints
101
+ * - `.detail = { meshEntity, jointIndex }`
102
+ */
103
+ export class SkinJointDespawnedError extends Error {
104
+ readonly code = 'skin-joint-despawned' as const;
105
+ readonly expected: string;
106
+ readonly hint: string;
107
+ readonly detail: SkinJointDespawnedDetail;
108
+
109
+ constructor(meshEntity: number, jointIndex: number) {
110
+ const expected = `Skin.joints[${jointIndex}] references a live entity`;
111
+ const hint = `joint[${jointIndex}] of entity ${meshEntity} has been despawned; remove Skin component or re-spawn the joint entity (OOS-skin-joint-respawn)`;
112
+ super(`skin joint[${jointIndex}] despawned for entity ${meshEntity}`);
113
+ this.name = 'SkinJointDespawnedError';
114
+ this.expected = expected;
115
+ this.hint = hint;
116
+ this.detail = { meshEntity, jointIndex };
117
+ }
118
+ }
119
+
120
+ // ── SkinJointPathUnresolvedError ────────────────────────────────────────
121
+
122
+ /**
123
+ * Detail for `RuntimeErrorCode 'skin-joint-path-unresolved'`.
124
+ *
125
+ * Emitted at post-spawn time when a jointPath leaf name cannot be found.
126
+ */
127
+ export interface SkinJointPathUnresolvedDetail {
128
+ readonly skinEntity: number;
129
+ readonly path: readonly string[];
130
+ readonly failedAtIndex: number;
131
+ }
132
+
133
+ /**
134
+ * Structured error for unresolved jointPath post-spawn.
135
+ *
136
+ * Emitted by resolveSkinJoints when Name lookup fails.
137
+ * - `.code = 'skin-joint-path-unresolved'`
138
+ * - `.expected` — Name-bearing entity exists for each jointPath leaf
139
+ * - `.hint` — verify glTF node Name preservation in the importer
140
+ * - `.detail = { skinEntity, path, failedAtIndex }`
141
+ */
142
+ export class SkinJointPathUnresolvedError extends Error {
143
+ readonly code = 'skin-joint-path-unresolved' as const;
144
+ readonly expected: string;
145
+ readonly hint: string;
146
+ readonly detail: SkinJointPathUnresolvedDetail;
147
+
148
+ constructor(skinEntity: number, path: readonly string[], failedAtIndex: number) {
149
+ const leafName = path[failedAtIndex] ?? '<unknown>';
150
+ const expected = `joint entity with Name="${leafName}" exists in the world`;
151
+ const hint = `joint path "${path.join('/')}" for skin entity ${skinEntity} could not be resolved; verify glTF node names are preserved`;
152
+ super(
153
+ `joint path "${path.join('/')}" unresolved at index ${failedAtIndex} for entity ${skinEntity}`,
154
+ );
155
+ this.name = 'SkinJointPathUnresolvedError';
156
+ this.expected = expected;
157
+ this.hint = hint;
158
+ this.detail = { skinEntity, path, failedAtIndex };
159
+ }
160
+ }
161
+
162
+ // ── SkinInstancesCoexistForbiddenError ──────────────────────────────────
163
+
164
+ /**
165
+ * Detail for `RuntimeErrorCode 'skin-instances-coexist-forbidden'`.
166
+ *
167
+ * Emitted at extract time when Skin + Instances coexist on the same entity.
168
+ */
169
+ export interface SkinInstancesCoexistForbiddenDetail {
170
+ readonly entity: number;
171
+ }
172
+
173
+ /**
174
+ * Structured error for Skin + Instances coexistence on same entity.
175
+ *
176
+ * Emitted at extract time; the entity draw is skipped.
177
+ * - `.code = 'skin-instances-coexist-forbidden'`
178
+ * - `.expected` — Skin and Instances on separate entities
179
+ * - `.hint` — split skinned meshes from instanced meshes into separate entities (OOS-skin-instances-coexist)
180
+ * - `.detail = { entity }`
181
+ */
182
+ export class SkinInstancesCoexistForbiddenError extends Error {
183
+ readonly code = 'skin-instances-coexist-forbidden' as const;
184
+ readonly expected: string;
185
+ readonly hint: string;
186
+ readonly detail: SkinInstancesCoexistForbiddenDetail;
187
+
188
+ constructor(entity: number) {
189
+ const expected = 'Skin and Instances must not coexist on the same entity';
190
+ const hint = `entity ${entity} has both Skin and Instances; split skinned meshes from instanced meshes into separate entities (OOS-skin-instances-coexist)`;
191
+ super(`Skin + Instances coexistence forbidden on entity ${entity}`);
192
+ this.name = 'SkinInstancesCoexistForbiddenError';
193
+ this.expected = expected;
194
+ this.hint = hint;
195
+ this.detail = { entity };
196
+ }
197
+ }
198
+
199
+ // ── SkeletonResolveFailedError ─────────────────────────────────────────────
200
+
201
+ /**
202
+ * Detail for `RuntimeErrorCode 'skeleton-resolve-failed'`.
203
+ *
204
+ * Emitted at extract time when `assets.get<SkeletonAsset>(skin.skeleton)`
205
+ * returns `null` / `undefined`. The skeleton handle is non-zero (the entity
206
+ * declared a Skin) but the asset is not registered (importer drift /
207
+ * AssetRegistry not warmed).
208
+ */
209
+ export interface SkeletonResolveFailedDetail {
210
+ readonly entity: number;
211
+ readonly skeletonHandle: number;
212
+ }
213
+
214
+ /**
215
+ * Structured error for unresolved SkeletonAsset handle at extract time
216
+ * (feat-20260612-skin-palette-per-frame-upload M2 / m2-5).
217
+ *
218
+ * Emitted at extract time; the entity draw is skipped (continue), other
219
+ * entities in the same frame keep extracting.
220
+ * - `.code = 'skeleton-resolve-failed'`
221
+ * - `.expected` — Skin.skeleton handle resolves to a registered SkeletonAsset
222
+ * - `.hint` — verify SkeletonAsset is imported into pack-index AND registered
223
+ * via AssetRegistry.register(handle, asset) before extractFrame
224
+ * - `.detail = { entity, skeletonHandle }`
225
+ */
226
+ export class SkeletonResolveFailedError extends Error {
227
+ readonly code = 'skeleton-resolve-failed' as const;
228
+ readonly expected: string;
229
+ readonly hint: string;
230
+ readonly detail: SkeletonResolveFailedDetail;
231
+
232
+ constructor(entity: number, skeletonHandle: number) {
233
+ const expected = `Skin.skeleton handle ${skeletonHandle} resolves to a registered SkeletonAsset`;
234
+ const hint = `entity ${entity} Skin.skeleton handle ${skeletonHandle} is not registered; check that the SkeletonAsset went through the gltf importer into pack-index AND that AssetRegistry.register was called for the handle before extractFrame runs`;
235
+ super(`Skin skeleton resolve failed on entity ${entity}: handle ${skeletonHandle}`);
236
+ this.name = 'SkeletonResolveFailedError';
237
+ this.expected = expected;
238
+ this.hint = hint;
239
+ this.detail = { entity, skeletonHandle };
240
+ }
241
+ }
242
+
243
+ // ── JointCountMismatchError ────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Detail for `RuntimeErrorCode 'joint-count-mismatch'`.
247
+ *
248
+ * Emitted at extract time when `Skin.joints.length !== SkeletonAsset.jointCount`.
249
+ * `expected` is the SkeletonAsset's jointCount (the source of truth);
250
+ * `actual` is the entity's `Skin.joints.length` (the runtime entity reference
251
+ * list materialized at post-spawn time).
252
+ */
253
+ export interface JointCountMismatchDetail {
254
+ readonly entity: number;
255
+ readonly expected: number;
256
+ readonly actual: number;
257
+ }
258
+
259
+ /**
260
+ * Structured error for SkinAsset.joints[] vs SkeletonAsset.jointCount disagreement
261
+ * (feat-20260612-skin-palette-per-frame-upload M2 / m2-5).
262
+ *
263
+ * Emitted at extract time; the entity draw is skipped (continue).
264
+ * - `.code = 'joint-count-mismatch'`
265
+ * - `.expected` — Skin.joints.length === SkeletonAsset.jointCount
266
+ * - `.hint` — verify SkinAsset.joints[] and SkeletonAsset jointPaths[]
267
+ * come from the same glTF skin node
268
+ * - `.detail = { entity, expected, actual }`
269
+ */
270
+ export class JointCountMismatchError extends Error {
271
+ readonly code = 'joint-count-mismatch' as const;
272
+ readonly expected: string;
273
+ readonly hint: string;
274
+ readonly detail: JointCountMismatchDetail;
275
+
276
+ constructor(entity: number, expected: number, actual: number) {
277
+ const expectedStr = `Skin.joints.length === SkeletonAsset.jointCount (=${expected})`;
278
+ const hint = `entity ${entity}: Skin.joints.length=${actual} disagrees with SkeletonAsset.jointCount=${expected}; verify SkinAsset.joints[] and SkeletonAsset jointPaths[] come from the same glTF skin node`;
279
+ super(
280
+ `joint count mismatch on entity ${entity}: SkeletonAsset.jointCount=${expected}, Skin.joints.length=${actual}`,
281
+ );
282
+ this.name = 'JointCountMismatchError';
283
+ this.expected = expectedStr;
284
+ this.hint = hint;
285
+ this.detail = { entity, expected, actual };
286
+ }
287
+ }
288
+
289
+ // ── JointEntityDanglingError ──────────────────────────────────────────────
290
+
291
+ /**
292
+ * Detail for `RuntimeErrorCode 'joint-entity-dangling'`.
293
+ *
294
+ * Emitted at extract time when `Skin.joints[i]` points at an Entity that has
295
+ * been despawned (or lost its Transform component) so
296
+ * `worldInternal._getArrayView(jointEntity, Transform, 'world')` returns
297
+ * undefined. `jointIndex` is the position within `Skin.joints[]`.
298
+ */
299
+ export interface JointEntityDanglingDetail {
300
+ readonly entity: number;
301
+ readonly jointIndex: number;
302
+ }
303
+
304
+ /**
305
+ * Structured error for despawned (or Transform-less) joint Entity at extract
306
+ * time (feat-20260612-skin-palette-per-frame-upload M2 / m2-5).
307
+ *
308
+ * Distinct from the pre-existing `SkinJointDespawnedError` which fires from
309
+ * advanceAnimationPlayer (animation-stage); this one fires from extractFrame
310
+ * (palette-upload stage) when the per-joint world mat4 view is missing.
311
+ *
312
+ * Emitted at extract time; the entity draw is skipped (continue).
313
+ * - `.code = 'joint-entity-dangling'`
314
+ * - `.expected` — Skin.joints[i] references a live Entity with Transform
315
+ * - `.hint` — sync Skin.joints[] when joint entities are despawned, or
316
+ * re-import the scene through the gltf importer to refresh Entity refs
317
+ * - `.detail = { entity, jointIndex }`
318
+ */
319
+ export class JointEntityDanglingError extends Error {
320
+ readonly code = 'joint-entity-dangling' as const;
321
+ readonly expected: string;
322
+ readonly hint: string;
323
+ readonly detail: JointEntityDanglingDetail;
324
+
325
+ constructor(entity: number, jointIndex: number) {
326
+ const expected = `Skin.joints[${jointIndex}] references a live Entity with Transform`;
327
+ const hint = `entity ${entity} Skin.joints[${jointIndex}] points at a despawned (or Transform-less) Entity; sync Skin.joints[] when joint entities are despawned, or re-import the scene through the gltf importer to refresh Entity references`;
328
+ super(`joint entity dangling on entity ${entity} at jointIndex ${jointIndex}`);
329
+ this.name = 'JointEntityDanglingError';
330
+ this.expected = expected;
331
+ this.hint = hint;
332
+ this.detail = { entity, jointIndex };
333
+ }
334
+ }
335
+
336
+ // -- SkinErrorCode / SkinError closed unions ------------------------------------
337
+
338
+ /**
339
+ * Closed union of skin-cluster error codes derived from the correlated error
340
+ * union. AI users perform exhaustive `switch (err.code)` without default; TS
341
+ * guards completeness.
342
+ */
343
+ export type SkinErrorCode = SkinError['code'];
344
+
345
+ /**
346
+ * Closed union of the skin-cluster structured error classes, each carrying a
347
+ * `SkinErrorCode` discriminant on `.code`.
348
+ */
349
+ export type SkinError =
350
+ | SkinJointCountExceededError
351
+ | SkinJointDespawnedError
352
+ | SkinJointPathUnresolvedError
353
+ | SkinInstancesCoexistForbiddenError
354
+ | SkeletonResolveFailedError
355
+ | JointCountMismatchError
356
+ | JointEntityDanglingError;
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { skeletonContribution, skinContribution } from './assets/skin-decoder';
2
+ export {
3
+ JointCountMismatchError,
4
+ JointEntityDanglingError,
5
+ SkeletonResolveFailedError,
6
+ type SkinError,
7
+ type SkinErrorCode,
8
+ SkinInstancesCoexistForbiddenError,
9
+ SkinJointCountExceededError,
10
+ SkinJointDespawnedError,
11
+ SkinJointPathUnresolvedError,
12
+ } from './errors';
13
+ export { skinningPlugin } from './plugin';
14
+ export { resolveSkinJoints } from './resolve-skin-joints';
15
+ export { Skin } from './skin';
package/src/plugin.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Component, World } from '@forgeax/engine-ecs';
2
+ import type { Plugin } from '@forgeax/engine-plugin';
3
+ import { Skin } from './skin';
4
+
5
+ const SKINNING_COMPONENTS: readonly Component[] = [Skin];
6
+
7
+ function registerSkinningComponents(world: World): () => void {
8
+ const leases = SKINNING_COMPONENTS.map((component) =>
9
+ world.components.register(component).unwrap(),
10
+ );
11
+ return () => {
12
+ for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
13
+ };
14
+ }
15
+
16
+ /** Install skeletal binding components in a World that consumes skinned scenes. */
17
+ export function skinningPlugin(): Plugin {
18
+ return {
19
+ name: 'skinning',
20
+ inject: ['world'],
21
+ apply(ctx) {
22
+ ctx.effect(() => registerSkinningComponents(ctx.world), 'skinning/components');
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,24 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import { type SkinError, SkinJointPathUnresolvedError } from './errors.js';
3
+
4
+ export function resolveSkinJoints(
5
+ jointPaths: readonly string[],
6
+ names: ReadonlyMap<string, EntityHandle>,
7
+ skinEntity: EntityHandle,
8
+ ): { ok: true; value: Uint32Array } | { ok: false; error: SkinError } {
9
+ const joints: number[] = [];
10
+ for (const path of jointPaths) {
11
+ const segments = path.split('/').filter(Boolean);
12
+ if (segments.length === 0) continue;
13
+ const failedAtIndex = segments.length - 1;
14
+ const entity = names.get(segments[failedAtIndex] ?? '');
15
+ if (entity === undefined) {
16
+ return {
17
+ ok: false,
18
+ error: new SkinJointPathUnresolvedError(skinEntity as number, segments, failedAtIndex),
19
+ };
20
+ }
21
+ joints.push(entity as number);
22
+ }
23
+ return { ok: true, value: new Uint32Array(joints) };
24
+ }
package/src/skin.ts ADDED
@@ -0,0 +1,68 @@
1
+ // @forgeax/engine-runtime - Skin component (skeleton handle + joint Entity slots).
2
+ //
3
+ // Schema: { skeleton: 'shared<SkeletonAsset>', joints: 'array<entity>' }.
4
+ //
5
+ // `skeleton` carries the immutable SkeletonAsset handle (IBM + jointCount);
6
+ // `joints` carries the live Entity[] resolved at post-spawn time from the
7
+ // SkinAsset.jointPaths via Name-component BFS/DFS lookup. The joint list is
8
+ // consumed by advanceAnimationPlayer (write target) and render-system-extract
9
+ // (CPU palette pre-multiply source).
10
+ //
11
+ // Naming: single-semantic component drops the 'Component' suffix
12
+ // (AGENTS.md §Component naming rule #1). `joints` field takes the holder's
13
+ // perspective (AGENTS.md §Component naming rule #3).
14
+ //
15
+ // Component registered alongside MeshFilter / MeshRenderer / Transform as
16
+ // a sibling on the same entity (AC-13 / AC-37). Skin + Instances coexistence
17
+ // on the same entity is forbidden (M2 fail-fast 'skin-instances-coexist-forbidden').
18
+ //
19
+ // Decision anchors:
20
+ // - requirements AC-13 (Skin sibling to MeshFilter / MeshRenderer)
21
+ // - requirements AC-15 (joint Entity slots, no marker component)
22
+ // - requirements AC-37 (no Component suffix)
23
+ // - plan-strategy D-10 (SkinPaletteSlice naming + Skin x Instances fail-fast)
24
+ // - charter P3 (explicit failure: joint despawn fail-fast)
25
+ // - schema vocab 'shared<SkeletonAsset>' v1 missing item #4 alignment
26
+ //
27
+ // ## Transform contract (post-bug-20260615 fix)
28
+ //
29
+ // **Old (buggy) implicit contract (pre-bug-20260615):** The Skin entity's
30
+ // Transform.world was double-applied during skinning -- the shader computed
31
+ // `world = meshes[0].worldFromLocal x palette x pos`, so any non-identity
32
+ // Transform on the Skin entity (or its non-joint ancestors) caused doubled
33
+ // motion (translation 2x, rotation 2x). Holders had to manually pin the Skin
34
+ // entity's Transform to identity to avoid doubled motion. This contract was
35
+ // undocumented and easy to violate.
36
+ //
37
+ // **New explicit contract (post-bug-20260615 fix):** An entity carrying `Skin`
38
+ // has its own `Transform` ignored at render time; the world transform is
39
+ // determined entirely by the joints' world matrices fed through the palette:
40
+ //
41
+ // palette[i] = jointWorld_i x IBM_i
42
+ // shader: world = palette[i] x pos
43
+ //
44
+ // No additional left-multiply by `meshes[0].worldFromLocal` or `instanceLocal`.
45
+ // To move the rig, parent the joint root (or any common ancestor of the joints
46
+ // in `Skin.joints[]`) to your driving entity -- moving the Skin entity itself
47
+ // has no rendering effect. This aligns with glTF 2.0 SSkins Implementation
48
+ // Note: "the transform of the node that the mesh is attached to must be
49
+ // ignored when performing skinning."
50
+ //
51
+ // Full pipeline documentation: packages/runtime/README.md
52
+ // SSkinPaletteAllocator.
53
+ //
54
+ // Fix commits:
55
+ // - M0 (red): 15425c2b -- parented skin double-transform unit test
56
+ // - M1 (green): 2ad509b7 -- shader Plan A: drop meshes[0] left-multiply
57
+ // - M2 (cleanup): 4118e463 -- extract.ts joint read -> world.get API
58
+ // - M3 (demo): 94d7db66 -- hello-skin parented Fox under non-identity rig
59
+ // - M4 (baseline): b6ddf46d -- palette-hash counter-proof + submodule pointer
60
+
61
+ import { defineComponent } from '@forgeax/engine-ecs';
62
+
63
+ export const Skin = defineComponent('Skin', {
64
+ // The renderer owns the live skeleton asset/palette binding; joint entity
65
+ // relationships remain the portable simulation-side pose contract.
66
+ skeleton: { type: 'shared<SkeletonAsset>' },
67
+ joints: { type: 'array<entity>' },
68
+ });