@umicat/three-sdk 0.17.3 → 0.19.0

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 CHANGED
@@ -73,6 +73,18 @@ Each rule is a decision:
73
73
  Guessing that every model calls its walk cycle `Walk` fails silently; an
74
74
  independent review's cross-rig retarget returned zero matched bones and zero
75
75
  tracks, which is the same class of failure, quieter.
76
+ - **A prop declares which socket it belongs in** (`socket: 'hand-right'` on the
77
+ sword, `sockets: { 'hand-right': {...} }` on the character). Both halves are
78
+ properties of the MODEL, not of any game — a cap goes on a head in every game
79
+ there has ever been — so a game that owns a character pack does not also have
80
+ to own a lookup table saying a cap is headwear.
81
+ - **Skins are declared per asset and chosen per entity**
82
+ (`skins: { zombie: 'skins/zombieA.png' }`, then `skin: 'zombie'`). A body and
83
+ its wardrobe are separate files, so N bodies × M skins costs N + M assets
84
+ rather than N × M baked glb files. It is authored data rather than a call a
85
+ game makes, for the same reason the clip map is: a capability only reachable
86
+ from code is one most games never find, and an editor cannot offer a list it
87
+ cannot see.
76
88
 
77
89
  `validateScene` refuses duplicate ids, dangling parents, entities that would
78
90
  render nothing, trimesh colliders on dynamic bodies, and malformed quaternions —
@@ -66,6 +66,7 @@ export interface LoadedScene3D {
66
66
  * Returns a fresh object each call, so two swords do not share one transform. */
67
67
  export declare function loadModelAsset(manifest: Manifest3D, modelAssetId: string, opts?: {
68
68
  assetBase?: string;
69
+ skin?: string;
69
70
  }): Promise<{
70
71
  object: THREE.Object3D;
71
72
  clips: THREE.AnimationClip[];
@@ -1,9 +1,18 @@
1
1
  import * as THREE from 'three';
2
2
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
+ import { clone as cloneRigged } from 'three/examples/jsm/utils/SkeletonUtils.js';
3
4
  import { validateScene, IDENTITY_QUAT, } from './scene3d.js';
5
+ import { applySkin } from './Skin.js';
4
6
  function toVec(v, d = 0) {
5
7
  return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
6
8
  }
9
+ /** Does this model deform with bones? Decides which clone it needs. */
10
+ function isRigged(root) {
11
+ let rigged = false;
12
+ root.traverse((o) => { if (o.isSkinnedMesh)
13
+ rigged = true; });
14
+ return rigged;
15
+ }
7
16
  function applyTransform(obj, e) {
8
17
  const t = e.transform;
9
18
  obj.position.copy(toVec(t.position));
@@ -62,6 +71,14 @@ export async function loadModelAsset(manifest, modelAssetId, opts = {}) {
62
71
  }
63
72
  const gltf = await new GLTFLoader().loadAsync((opts.assetBase ?? '') + asset.path);
64
73
  const object = gltf.scene;
74
+ if (opts.skin) {
75
+ const path = asset.skins?.[opts.skin];
76
+ if (!path) {
77
+ throw new Error(`[umicat] model '${modelAssetId}' has no skin '${opts.skin}'. It declares: ` +
78
+ `${Object.keys(asset.skins ?? {}).join(', ') || '(none)'}`);
79
+ }
80
+ await applySkin(object, path, { assetBase: opts.assetBase });
81
+ }
65
82
  if (asset.importScale !== undefined)
66
83
  object.scale.setScalar(asset.importScale);
67
84
  object.userData.modelAssetId = modelAssetId;
@@ -115,16 +132,31 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
115
132
  const clips = new Map();
116
133
  for (const [id, gltf] of loaded)
117
134
  clips.set(id, gltf.animations);
135
+ // Skins are fetched per entity and awaited before this function resolves, so
136
+ // a caller that awaits `loadScene3D` gets a scene that is finished — not one
137
+ // whose cast pops from placeholder to costume a frame or two later.
138
+ const skinJobs = [];
118
139
  for (const e of scene3d.entities) {
119
140
  let obj;
120
141
  if (e.modelAssetId) {
121
142
  const gltf = loaded.get(e.modelAssetId);
122
143
  const asset = models.get(e.modelAssetId);
123
- // SkeletonUtils.clone would be needed for skinned meshes sharing a model;
124
- // a single instance per asset is the case the slice covers, so clone only
125
- // when a second entity wants the same asset.
144
+ // Clone only when a second entity wants the same asset — the first one
145
+ // gets the loaded scene itself.
146
+ //
147
+ // **A rigged model must be cloned with `SkeletonUtils.clone`.**
148
+ // `Object3D.clone()` copies the bone hierarchy but leaves the cloned
149
+ // SkinnedMesh pointing at the ORIGINAL `Skeleton`, so every clone
150
+ // deforms with the first one's bones: a squad of guards all play the
151
+ // same animation in the same place, stacked on the leader, while each
152
+ // one's own mixer reports that it is playing something else. Nothing
153
+ // errors. This became load-bearing the moment one body plus a set of
154
+ // skins became the way characters ship — "many instances of one model"
155
+ // went from a corner to the main case.
126
156
  const used = [...entities.values()].some((o) => o.userData.modelAssetId === e.modelAssetId);
127
- obj = used ? gltf.scene.clone(true) : gltf.scene;
157
+ obj = !used ? gltf.scene
158
+ : isRigged(gltf.scene) ? cloneRigged(gltf.scene)
159
+ : gltf.scene.clone(true);
128
160
  obj.userData.modelAssetId = e.modelAssetId;
129
161
  if (asset.importScale)
130
162
  obj.scale.setScalar(asset.importScale);
@@ -160,6 +192,23 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
160
192
  mixers.push(mixer);
161
193
  mixerFor.set(e.id, mixer);
162
194
  }
195
+ if (e.skin) {
196
+ const path = asset.skins?.[e.skin];
197
+ if (!path) {
198
+ // Same contract as a missing clip: say which names DO exist. A skin
199
+ // that silently doesn't apply looks exactly like a model built with
200
+ // the wrong default, and you go looking in Blender.
201
+ throw new Error(`entity '${e.id}' wants skin '${e.skin}', but model '${e.modelAssetId}' declares: ` +
202
+ `${Object.keys(asset.skins ?? {}).join(', ') || '(none)'}`);
203
+ }
204
+ const target = obj;
205
+ skinJobs.push(applySkin(target, path, { assetBase: opts.assetBase }).then((r) => {
206
+ if (r.repainted === 0) {
207
+ throw new Error(`entity '${e.id}' skin '${e.skin}' repainted nothing — model ` +
208
+ `'${e.modelAssetId}' has no material with a base-colour texture to replace`);
209
+ }
210
+ }));
211
+ }
163
212
  }
164
213
  else {
165
214
  obj = makePrimitive(e);
@@ -173,6 +222,8 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
173
222
  applyTransform(obj, e);
174
223
  entities.set(e.id, obj);
175
224
  }
225
+ if (skinJobs.length)
226
+ await Promise.all(skinJobs);
176
227
  // Parent after every entity exists, so declaration order doesn't matter.
177
228
  for (const e of scene3d.entities) {
178
229
  const obj = entities.get(e.id);
@@ -220,8 +271,22 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
220
271
  // hits it immediately.
221
272
  {
222
273
  const bounds = new THREE.Box3();
274
+ // precise:true is load-bearing. Box3.expandByObject's default path, for any
275
+ // object exposing its own `.boundingBox` (only THREE.SkinnedMesh does),
276
+ // calls `object.computeBoundingBox()` and PERMANENTLY CACHES the result on
277
+ // the mesh — and for `bindMode: 'attached'` (GLTFLoader's default), that
278
+ // computation folds in each bone's absolute matrixWorld, so the cached box
279
+ // ends up sitting at the entity's placed-in-scene position. `raycast()`
280
+ // then treats that same cached box as LOCAL space and inverts matrixWorld
281
+ // before testing against it, so every ray misses. Net effect: any rigged
282
+ // model entity (a hero, an NPC) becomes permanently unraycastable — hit
283
+ // everywhere on screen except the one thing worth clicking — the moment
284
+ // this shadow-fitting pass runs. `precise:true` walks vertices directly
285
+ // instead and never touches `object.boundingBox`. Confirmed by direct
286
+ // repro against a shipped game's rigged `hero` entity: unclickable with
287
+ // the default call, hits correctly once this switched to precise.
223
288
  for (const o of entities.values())
224
- bounds.expandByObject(o);
289
+ bounds.expandByObject(o, true);
225
290
  if (!bounds.isEmpty()) {
226
291
  const size = bounds.getSize(new THREE.Vector3());
227
292
  const centre = bounds.getCenter(new THREE.Vector3());
package/dist/Skin.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ import * as THREE from 'three';
2
+ /**
3
+ * Repaint a character by swapping its base-colour texture.
4
+ *
5
+ * One rigged body plus N textures is how a character pack is actually shipped
6
+ * — the Kenney set every Umicat 3D game draws from has four bodies, seventeen
7
+ * clips and eighty-one skins painted on ONE shared UV layout, which is 324
8
+ * characters from 85 files. Baking the combinations instead would be 324 glb
9
+ * files, the same mesh and the same clips repeated 324 times, and a palette
10
+ * swap would cost a fresh megabyte-and-a-half download.
11
+ *
12
+ * It is a platform primitive rather than `mat.map = tex` in each game because
13
+ * of three traps, each of which fails quietly:
14
+ *
15
+ * **Clones share materials.** `gltf.scene.clone(true)` hands every clone the
16
+ * same material instance, so reskinning one zombie reskins the whole horde.
17
+ * `Tint.ts` exists for the same reason and its notes tell the same story: the
18
+ * bug reads as a gameplay bug, not a graphics one. Materials are therefore
19
+ * cloned per object, once, the first time it is skinned.
20
+ *
21
+ * **A plainly-loaded texture is upside down.** `GLTFLoader` sets `flipY =
22
+ * false` on everything it loads (glTF puts the UV origin top-left);
23
+ * `TextureLoader` defaults to `true`. So the obvious one-liner gives you a
24
+ * character wearing its own texture mirrored vertically — shoes on the head —
25
+ * and nothing anywhere reports a problem. The replacement copies its settings
26
+ * from the map it replaces, which also preserves the model's authored
27
+ * filtering: these skins are region maps, and a bilinear tap straddling two
28
+ * regions invents a colour that is in neither.
29
+ *
30
+ * **Not every material is skin.** A character carrying a sword has the sword's
31
+ * steel in the same subtree. Repainting every material would paint the blade
32
+ * with a face. The default target is "materials that already have a base
33
+ * colour map", which selects the textured body and leaves flat palette colours
34
+ * alone — and correctly follows through to attachments that ARE skin-painted,
35
+ * like the animal ears and tails, whose material is the character's own.
36
+ */
37
+ export interface SkinOptions {
38
+ /**
39
+ * Narrow which materials get repainted, by exact name or pattern. Omit to
40
+ * repaint every material that already has a base-colour map, which is the
41
+ * right answer for a body-plus-props subtree.
42
+ */
43
+ material?: string | RegExp;
44
+ /**
45
+ * Reuse one `THREE.Texture` per url across every call. Default true — a
46
+ * squad of eight guards in the same uniform should download and upload one
47
+ * texture, not eight. Turn it off only if a game mutates the texture it gets.
48
+ */
49
+ cache?: boolean;
50
+ /** Where to resolve a relative url against, e.g. the project's asset host. */
51
+ assetBase?: string;
52
+ }
53
+ /** What a skin swap did, so a caller can tell "worked" from "hit nothing". */
54
+ export interface SkinResult {
55
+ /** How many materials were repainted. Zero means the filter matched nothing. */
56
+ readonly repainted: number;
57
+ /** The texture now in use, for a game that wants to reuse it directly. */
58
+ readonly texture: THREE.Texture;
59
+ }
60
+ /**
61
+ * Put `texture` on `object` as its base colour. The synchronous half — use it
62
+ * when a game already holds the texture (a preloaded team colour, an atlas
63
+ * page); otherwise use `applySkin`, which fetches.
64
+ *
65
+ * The texture is NOT disposed on replacement: the map coming off usually came
66
+ * from the glb and is shared with every other instance of that model, so
67
+ * disposing it blanks the rest of the cast.
68
+ */
69
+ export declare function applySkinTexture(object: THREE.Object3D, texture: THREE.Texture, opts?: SkinOptions): SkinResult;
70
+ /**
71
+ * Fetch a skin texture and put it on `object`.
72
+ *
73
+ * Returns how many materials it actually repainted. **Zero is worth checking**
74
+ * — it means the filter matched nothing, which on a model whose body material
75
+ * has no texture at all is the difference between "reskinned" and "silently
76
+ * did nothing", and there is no other signal.
77
+ */
78
+ export declare function applySkin(object: THREE.Object3D, url: string, opts?: SkinOptions): Promise<SkinResult>;
79
+ /** Load (and by default cache) one skin texture. Exposed for preloading. */
80
+ export declare function loadSkinTexture(url: string, cache?: boolean): Promise<THREE.Texture>;
81
+ /** Drop the texture cache. For a game unloading a level, and for tests. */
82
+ export declare function clearSkinCache(): void;
package/dist/Skin.js ADDED
@@ -0,0 +1,133 @@
1
+ import * as THREE from 'three';
2
+ /** Objects whose materials have already been given their own copies. */
3
+ const isolated = new WeakSet();
4
+ const textures = new Map();
5
+ let loader = null;
6
+ function matches(material, filter) {
7
+ if (filter === undefined) {
8
+ // Untextured materials are palette colours — a sword's steel, a cap's
9
+ // felt. They are not skin and must not be painted with one.
10
+ return material.map != null;
11
+ }
12
+ return typeof filter === 'string' ? material.name === filter : filter.test(material.name);
13
+ }
14
+ /**
15
+ * Give `object` its own copy of every material it uses, so painting it paints
16
+ * only it. Idempotent: the second call on the same object does nothing.
17
+ */
18
+ function isolate(object) {
19
+ if (isolated.has(object))
20
+ return;
21
+ isolated.add(object);
22
+ object.traverse((o) => {
23
+ const mesh = o;
24
+ if (!mesh.isMesh)
25
+ return;
26
+ mesh.material = Array.isArray(mesh.material)
27
+ ? mesh.material.map((m) => m.clone())
28
+ : mesh.material.clone();
29
+ });
30
+ }
31
+ /**
32
+ * Point `texture` at the same sampling the map it replaces was authored with.
33
+ *
34
+ * This is the whole reason a skin swap is not a one-liner. `flipY` alone is
35
+ * the difference between a character and a character wearing its texture
36
+ * upside down, and it differs between the loader that produced the model and
37
+ * the loader producing the replacement.
38
+ */
39
+ function matchSampling(texture, previous) {
40
+ if (previous) {
41
+ texture.flipY = previous.flipY;
42
+ texture.colorSpace = previous.colorSpace;
43
+ texture.wrapS = previous.wrapS;
44
+ texture.wrapT = previous.wrapT;
45
+ texture.magFilter = previous.magFilter;
46
+ texture.minFilter = previous.minFilter;
47
+ texture.anisotropy = previous.anisotropy;
48
+ texture.generateMipmaps = previous.generateMipmaps;
49
+ texture.channel = previous.channel;
50
+ }
51
+ else {
52
+ // No map to copy from: assume the glTF convention, since that is what
53
+ // every model this package loads was born as.
54
+ texture.flipY = false;
55
+ texture.colorSpace = THREE.SRGBColorSpace;
56
+ }
57
+ texture.needsUpdate = true;
58
+ }
59
+ /**
60
+ * Put `texture` on `object` as its base colour. The synchronous half — use it
61
+ * when a game already holds the texture (a preloaded team colour, an atlas
62
+ * page); otherwise use `applySkin`, which fetches.
63
+ *
64
+ * The texture is NOT disposed on replacement: the map coming off usually came
65
+ * from the glb and is shared with every other instance of that model, so
66
+ * disposing it blanks the rest of the cast.
67
+ */
68
+ export function applySkinTexture(object, texture, opts = {}) {
69
+ isolate(object);
70
+ let repainted = 0;
71
+ const done = new Set();
72
+ object.traverse((o) => {
73
+ const mesh = o;
74
+ if (!mesh.isMesh)
75
+ return;
76
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
77
+ for (const m of list) {
78
+ if (done.has(m) || !matches(m, opts.material))
79
+ continue;
80
+ done.add(m);
81
+ const std = m;
82
+ // Clone per material rather than sharing one Texture object across
83
+ // materials: sampling is copied from whatever each one had, and two
84
+ // materials in the same model need not have been authored the same.
85
+ const tex = texture.clone();
86
+ matchSampling(tex, std.map ?? null);
87
+ std.map = tex;
88
+ std.needsUpdate = true;
89
+ repainted += 1;
90
+ }
91
+ });
92
+ return { repainted, texture };
93
+ }
94
+ /**
95
+ * Fetch a skin texture and put it on `object`.
96
+ *
97
+ * Returns how many materials it actually repainted. **Zero is worth checking**
98
+ * — it means the filter matched nothing, which on a model whose body material
99
+ * has no texture at all is the difference between "reskinned" and "silently
100
+ * did nothing", and there is no other signal.
101
+ */
102
+ export async function applySkin(object, url, opts = {}) {
103
+ const full = (opts.assetBase ?? '') + url;
104
+ const texture = await loadSkinTexture(full, opts.cache !== false);
105
+ return applySkinTexture(object, texture, opts);
106
+ }
107
+ /** Load (and by default cache) one skin texture. Exposed for preloading. */
108
+ export function loadSkinTexture(url, cache = true) {
109
+ if (cache) {
110
+ const hit = textures.get(url);
111
+ if (hit)
112
+ return hit;
113
+ }
114
+ loader ?? (loader = new THREE.TextureLoader());
115
+ const p = loader.loadAsync(url).then((t) => {
116
+ t.flipY = false;
117
+ t.colorSpace = THREE.SRGBColorSpace;
118
+ return t;
119
+ });
120
+ if (cache) {
121
+ textures.set(url, p);
122
+ // A failed fetch must not be remembered as the answer: a skin that 404s
123
+ // once would then 404 for the rest of the session even after a fix.
124
+ p.catch(() => textures.delete(url));
125
+ }
126
+ return p;
127
+ }
128
+ /** Drop the texture cache. For a game unloading a level, and for tests. */
129
+ export function clearSkinCache() {
130
+ for (const p of textures.values())
131
+ p.then((t) => t.dispose()).catch(() => { });
132
+ textures.clear();
133
+ }
@@ -1,6 +1,7 @@
1
1
  import * as THREE from 'three';
2
2
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
3
  import { loadScene3D } from '../SceneLoader3D.js';
4
+ import { applySkin } from '../Skin.js';
4
5
  /**
5
6
  * The 3D counterpart of `@umicat/phaser-sdk`'s `EditorDesignScene` (ADR-021)
6
7
  * — renders a scene's AUTHORED data with no game code and no save, so the
@@ -70,6 +71,12 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
70
71
  }
71
72
  let scene = null;
72
73
  const entityByObject = new Map();
74
+ // The reverse direction, and the authored data behind it. Picking only ever
75
+ // needed object→id; editing a PROPERTY needs to go the other way, and needs
76
+ // the manifest to resolve what a name like `skin: "zombieA"` refers to.
77
+ const objectByEntity = new Map();
78
+ let manifest = null;
79
+ let sceneData = null;
73
80
  const resize = () => {
74
81
  renderer.setSize(window.innerWidth, window.innerHeight);
75
82
  camera.aspect = window.innerWidth / window.innerHeight;
@@ -218,6 +225,66 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
218
225
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
219
226
  renderer.domElement.addEventListener('pointermove', onPointerMove);
220
227
  renderer.domElement.addEventListener('pointerup', onPointerUp);
228
+ /**
229
+ * The FIRST host→iframe message this player has ever accepted.
230
+ *
231
+ * Everything until now went one way: the player reported picks and drags,
232
+ * and the host wrote them to disk. That works for a drag because the SDK
233
+ * itself already moved the object — the canvas was correct before the host
234
+ * heard about it. It does not work for a property edited in a panel: the
235
+ * host changes the file, the canvas knows nothing, and the edit is invisible
236
+ * until a rebuild. A property editor whose changes don't show is a property
237
+ * editor nobody trusts (a sibling 2D tool shipped exactly that bug — a new
238
+ * widget kind not wired into its live-patch path silently did nothing on
239
+ * canvas while the Inspector happily accepted the value).
240
+ *
241
+ * So: `patchEntity3d` in, `patchApplied3d` out. The ack matters as much as
242
+ * the patch — the host has ALREADY written the file by the time this runs,
243
+ * so a failure here means disk and canvas disagree, and the only way anyone
244
+ * finds out is if this says so.
245
+ *
246
+ * `patch` is an open bag deliberately: skin is the first field, and adding
247
+ * rotation/scale/visible later should be a new `if`, not a new message.
248
+ */
249
+ async function onHostMessage(event) {
250
+ // Source-identity, not origin: the host page and this iframe are on
251
+ // different origins by design (a CDN preview url), same as the checks the
252
+ // host does on messages coming the other way.
253
+ if (event.source !== window.parent)
254
+ return;
255
+ const data = event.data;
256
+ if (!data || typeof data !== 'object' || data.type !== 'umicat:editor:patchEntity3d')
257
+ return;
258
+ const m = data;
259
+ const done = (error) => {
260
+ if (error)
261
+ console.warn('[umicat/editor] 3D patch failed:', m.entityId, error);
262
+ window.parent.postMessage({ type: 'umicat:editor:patchApplied3d', entityId: m.entityId, ok: !error, error }, '*');
263
+ };
264
+ const object = objectByEntity.get(m.entityId);
265
+ if (!object)
266
+ return done(`no entity '${m.entityId}' in the scene being shown`);
267
+ if (m.patch?.skin !== undefined) {
268
+ const authored = sceneData?.entities.find((e) => e.id === m.entityId);
269
+ const asset = manifest?.models?.find((x) => x.id === authored?.modelAssetId);
270
+ const path = asset?.skins?.[m.patch.skin];
271
+ if (!path) {
272
+ return done(`model '${authored?.modelAssetId ?? '(none)'}' declares no skin '${m.patch.skin}'. ` +
273
+ `It has: ${Object.keys(asset?.skins ?? {}).join(', ') || '(none)'}`);
274
+ }
275
+ try {
276
+ const result = await applySkin(object, path, { assetBase: '' });
277
+ if (result.repainted === 0) {
278
+ return done(`skin '${m.patch.skin}' repainted nothing — this model has no textured material`);
279
+ }
280
+ }
281
+ catch (e) {
282
+ return done(e instanceof Error ? e.message : String(e));
283
+ }
284
+ }
285
+ done();
286
+ }
287
+ window.addEventListener('message', (e) => { void onHostMessage(e); });
221
288
  // Design mode skips physics and animation entirely (loadScene3D's own
222
289
  // designMode flag) — there are no mixers to advance and no Rapier world to
223
290
  // step, so the loop only ever needs to render.
@@ -252,13 +319,16 @@ export async function runEditorDesignPlayer3D(renderer, opts = {}) {
252
319
  if (!sceneId)
253
320
  return; // no scene chosen yet — idle, waiting for the host
254
321
  try {
255
- const manifest = await fetch('scenes3d/manifest.json').then((r) => r.json());
256
- const scene3d = await fetch(`scenes3d/${sceneId}.json`).then((r) => r.json());
257
- const loaded = await loadScene3D(scene3d, manifest, { assetBase: '', designMode: true });
322
+ manifest = await fetch('scenes3d/manifest.json').then((r) => r.json());
323
+ sceneData = await fetch(`scenes3d/${sceneId}.json`).then((r) => r.json());
324
+ const loaded = await loadScene3D(sceneData, manifest, { assetBase: '', designMode: true });
258
325
  scene = loaded.scene;
259
326
  entityByObject.clear();
260
- for (const [id, obj] of loaded.entities)
327
+ objectByEntity.clear();
328
+ for (const [id, obj] of loaded.entities) {
261
329
  entityByObject.set(obj, id);
330
+ objectByEntity.set(id, obj);
331
+ }
262
332
  marker.visible = false;
263
333
  scene.add(marker);
264
334
  selectionBox.visible = false;
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { Input3D } from './Input3D.js';
10
10
  export type { Input3DOptions, Input3DAction } from './Input3D.js';
11
11
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
12
  export { flashTint, updateTints, isTinted } from './Tint.js';
13
+ export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
14
+ export type { SkinOptions, SkinResult } from './Skin.js';
13
15
  export { GameAudio } from './GameAudio.js';
14
16
  export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
15
17
  export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { CharacterAnimator } from './CharacterAnimator.js';
11
11
  export { Input3D } from './Input3D.js';
12
12
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
13
  export { flashTint, updateTints, isTinted } from './Tint.js';
14
+ export { applySkin, applySkinTexture, loadSkinTexture, clearSkinCache } from './Skin.js';
14
15
  export { GameAudio } from './GameAudio.js';
15
16
  export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
16
17
  export { setupRecordingListener } from './capture/RecordingManager.js';
package/dist/scene3d.d.ts CHANGED
@@ -80,6 +80,9 @@ export interface Entity3D {
80
80
  play?: string;
81
81
  loop?: boolean;
82
82
  };
83
+ /** Which skin to wear, by name from the model's `skins` — see `SkinMap`.
84
+ * Omit to keep whatever texture the glb itself was built with. */
85
+ skin?: string;
83
86
  /** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
84
87
  properties?: Record<string, unknown>;
85
88
  visible?: boolean;
@@ -101,6 +104,17 @@ export interface Entity3D {
101
104
  * model calls its walk cycle `Walk` fails the same way, only more quietly.
102
105
  */
103
106
  export type AnimationMap = Record<string, string>;
107
+ /**
108
+ * Skin name → the texture that paints it, relative to the asset base.
109
+ *
110
+ * A character pack is one rigged body and many textures on one UV layout, so
111
+ * the body and its wardrobe are separate assets and the pairing is authored
112
+ * here rather than baked into a glb per combination. Declaring them means a
113
+ * scene picks a skin by name (`Entity3D.skin`) instead of a game writing load
114
+ * code, and an editor can offer the list — a capability only reachable from
115
+ * code is one most games never find.
116
+ */
117
+ export type SkinMap = Record<string, string>;
104
118
  /**
105
119
  * Where a held object sits on a character, named by what it is FOR rather
106
120
  * than by which bone it happens to hang off.
@@ -126,6 +140,19 @@ export interface ModelAsset3D {
126
140
  /** Resolved by the runtime against the project's asset base url. */
127
141
  path: string;
128
142
  animations?: AnimationMap;
143
+ /** Interchangeable textures for this model — `{ "zombie": "skins/zombieA.png" }`. */
144
+ skins?: SkinMap;
145
+ /**
146
+ * For a model that is WORN rather than worn-on: which of a character's
147
+ * `sockets` it belongs in — `"head"` for a cap, `"hand-right"` for a sword.
148
+ *
149
+ * The pairing is a property of the prop, not of any game: a cap goes on a
150
+ * head in every game that has ever existed. Without it, "attach this cap"
151
+ * needs a lookup table written per game, and twelve games write twelve
152
+ * slightly different ones — the same argument as the socket offsets living
153
+ * on the character (see `Socket3D`), from the other end.
154
+ */
155
+ socket?: string;
129
156
  /** Authoring correction applied at load: most models are not born 1 unit tall. */
130
157
  importScale?: number;
131
158
  /** Named attachment points — `{ "hand-right": { bone: "arm-right", ... } }`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.17.3",
3
+ "version": "0.19.0",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",