@dreamerbird/camera-studio 0.1.0 → 0.3.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/LICENSE +866 -0
- package/assets/models/Y Bot.glb +0 -0
- package/dist/CameraScene.d.ts +1 -1
- package/dist/CameraScene.d.ts.map +1 -1
- package/dist/CameraScene.js +100 -9
- package/dist/PlacementManager.d.ts +15 -1
- package/dist/PlacementManager.d.ts.map +1 -1
- package/dist/PlacementManager.js +151 -15
- package/dist/agent/api.d.ts +5 -0
- package/dist/agent/api.d.ts.map +1 -1
- package/dist/agent/api.js +25 -0
- package/dist/agent/api.test.js +75 -1
- package/dist/agent/commands/assets.d.ts +5 -0
- package/dist/agent/commands/assets.d.ts.map +1 -1
- package/dist/agent/commands/assets.js +93 -21
- package/dist/agent/commands/assets.test.js +53 -0
- package/dist/agent/commands/materials.d.ts +18 -0
- package/dist/agent/commands/materials.d.ts.map +1 -0
- package/dist/agent/commands/materials.js +192 -0
- package/dist/agent/commands/materials.test.d.ts +2 -0
- package/dist/agent/commands/materials.test.d.ts.map +1 -0
- package/dist/agent/commands/materials.test.js +294 -0
- package/dist/agent/commands/modelRecovery.test.d.ts +2 -0
- package/dist/agent/commands/modelRecovery.test.d.ts.map +1 -0
- package/dist/agent/commands/modelRecovery.test.js +122 -0
- package/dist/agent/commands/models.d.ts +13 -0
- package/dist/agent/commands/models.d.ts.map +1 -1
- package/dist/agent/commands/models.js +98 -1
- package/dist/agent/commands/primitives.d.ts +27 -0
- package/dist/agent/commands/primitives.d.ts.map +1 -0
- package/dist/agent/commands/primitives.js +117 -0
- package/dist/agent/commands/primitives.test.d.ts +2 -0
- package/dist/agent/commands/primitives.test.d.ts.map +1 -0
- package/dist/agent/commands/primitives.test.js +135 -0
- package/dist/agent/expectedCommands.d.ts +1 -1
- package/dist/agent/expectedCommands.d.ts.map +1 -1
- package/dist/agent/expectedCommands.js +6 -0
- package/dist/agent/propAccess.d.ts +2 -0
- package/dist/agent/propAccess.d.ts.map +1 -1
- package/dist/agent/propAccess.js +21 -0
- package/dist/agent/registerCommands.d.ts.map +1 -1
- package/dist/agent/registerCommands.js +5 -1
- package/dist/agent/runtime/objectRuntime.d.ts +2 -0
- package/dist/agent/runtime/objectRuntime.d.ts.map +1 -1
- package/dist/agent/runtime/objectRuntime.js +15 -1
- package/dist/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.d.ts.map +1 -1
- package/dist/agent/runtime.js +2 -0
- package/dist/assetLibrary/dispose.d.ts +4 -2
- package/dist/assetLibrary/dispose.d.ts.map +1 -1
- package/dist/assetLibrary/dispose.js +7 -5
- package/dist/assetLibrary/exportGLB.d.ts +25 -0
- package/dist/assetLibrary/exportGLB.d.ts.map +1 -1
- package/dist/assetLibrary/exportGLB.js +43 -1
- package/dist/assetLibrary/exportGLB.test.js +56 -2
- package/dist/assetLibrary/gltfLoader.d.ts +2 -1
- package/dist/assetLibrary/gltfLoader.d.ts.map +1 -1
- package/dist/assetLibrary/gltfLoader.js +2 -2
- package/dist/assetLibrary/gltfResourceSet.d.ts +6 -0
- package/dist/assetLibrary/gltfResourceSet.d.ts.map +1 -0
- package/dist/assetLibrary/gltfResourceSet.js +27 -0
- package/dist/assetLibrary/gltfResourceSet.test.d.ts +2 -0
- package/dist/assetLibrary/gltfResourceSet.test.d.ts.map +1 -0
- package/dist/assetLibrary/gltfResourceSet.test.js +40 -0
- package/dist/assetLibrary/importModelFile.d.ts +5 -0
- package/dist/assetLibrary/importModelFile.d.ts.map +1 -1
- package/dist/assetLibrary/importModelFile.js +3 -16
- package/dist/assetLibrary/importModelFiles.d.ts +15 -0
- package/dist/assetLibrary/importModelFiles.d.ts.map +1 -0
- package/dist/assetLibrary/importModelFiles.js +58 -0
- package/dist/assetLibrary/importModelFiles.test.d.ts +2 -0
- package/dist/assetLibrary/importModelFiles.test.d.ts.map +1 -0
- package/dist/assetLibrary/importModelFiles.test.js +217 -0
- package/dist/assetLibrary/importPipeline.d.ts +15 -1
- package/dist/assetLibrary/importPipeline.d.ts.map +1 -1
- package/dist/assetLibrary/importPipeline.js +74 -38
- package/dist/assetLibrary/importPipeline.test.js +25 -0
- package/dist/assetLibrary/library.d.ts +1 -0
- package/dist/assetLibrary/library.d.ts.map +1 -1
- package/dist/assetLibrary/library.js +22 -1
- package/dist/assetLibrary/library.test.js +76 -0
- package/dist/assetLibrary/seedAssetsManifest.js +1 -1
- package/dist/bootstrap.d.ts +2 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +69 -7
- package/dist/bootstrap.test.js +111 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/firstScreenGate.d.ts +28 -0
- package/dist/firstScreenGate.d.ts.map +1 -0
- package/dist/firstScreenGate.js +67 -0
- package/dist/firstScreenGate.test.d.ts +2 -0
- package/dist/firstScreenGate.test.d.ts.map +1 -0
- package/dist/firstScreenGate.test.js +83 -0
- package/dist/hostFlows.d.ts +1 -0
- package/dist/hostFlows.d.ts.map +1 -1
- package/dist/hostFlows.js +9 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/materials/modelMaterials.d.ts +19 -0
- package/dist/materials/modelMaterials.d.ts.map +1 -0
- package/dist/materials/modelMaterials.js +253 -0
- package/dist/materials/modelMaterials.test.d.ts +2 -0
- package/dist/materials/modelMaterials.test.d.ts.map +1 -0
- package/dist/materials/modelMaterials.test.js +188 -0
- package/dist/materials/types.d.ts +20 -0
- package/dist/materials/types.d.ts.map +1 -0
- package/dist/materials/types.js +1 -0
- package/dist/materials/useModelMaterials.d.ts +10 -0
- package/dist/materials/useModelMaterials.d.ts.map +1 -0
- package/dist/materials/useModelMaterials.js +26 -0
- package/dist/materials/useModelMaterials.test.d.ts +2 -0
- package/dist/materials/useModelMaterials.test.d.ts.map +1 -0
- package/dist/materials/useModelMaterials.test.js +75 -0
- package/dist/poses.d.ts +38 -0
- package/dist/poses.d.ts.map +1 -0
- package/dist/poses.js +41 -0
- package/dist/poses.test.d.ts +2 -0
- package/dist/poses.test.d.ts.map +1 -0
- package/dist/poses.test.js +38 -0
- package/dist/primitives.d.ts +5 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +15 -0
- package/dist/primitives.test.d.ts +2 -0
- package/dist/primitives.test.d.ts.map +1 -0
- package/dist/primitives.test.js +27 -0
- package/dist/reset.realStudio.test.d.ts +2 -0
- package/dist/reset.realStudio.test.d.ts.map +1 -0
- package/dist/reset.realStudio.test.js +60 -0
- package/dist/studioRoot.d.ts +4 -0
- package/dist/studioRoot.d.ts.map +1 -0
- package/dist/studioRoot.js +36 -0
- package/dist/studioRoot.test.d.ts +2 -0
- package/dist/studioRoot.test.d.ts.map +1 -0
- package/dist/studioRoot.test.js +62 -0
- package/dist/useCameraStudio.d.ts.map +1 -1
- package/dist/useCameraStudio.js +3 -2
- package/package.json +18 -10
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { types as t } from '@dreamerbird/core';
|
|
2
|
+
import { Mesh, SRGBColorSpace, } from 'three';
|
|
3
|
+
const textureProperties = [
|
|
4
|
+
['mapBlobId', 'map'],
|
|
5
|
+
['normalMapBlobId', 'normalMap'],
|
|
6
|
+
['roughnessMapBlobId', 'roughnessMap'],
|
|
7
|
+
['metalnessMapBlobId', 'metalnessMap'],
|
|
8
|
+
['emissiveMapBlobId', 'emissiveMap'],
|
|
9
|
+
];
|
|
10
|
+
const originalStates = new WeakMap();
|
|
11
|
+
const configCache = new WeakMap();
|
|
12
|
+
function materialRecord(material) {
|
|
13
|
+
return material;
|
|
14
|
+
}
|
|
15
|
+
function rememberOriginal(material) {
|
|
16
|
+
const existing = originalStates.get(material);
|
|
17
|
+
if (existing)
|
|
18
|
+
return existing;
|
|
19
|
+
const record = materialRecord(material);
|
|
20
|
+
const state = {
|
|
21
|
+
maps: {},
|
|
22
|
+
transparent: material.transparent,
|
|
23
|
+
};
|
|
24
|
+
for (const [, property] of textureProperties) {
|
|
25
|
+
state.maps[property] = record[property] ?? null;
|
|
26
|
+
}
|
|
27
|
+
originalStates.set(material, state);
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
function slotKey(slotId, meshName) {
|
|
31
|
+
let hash = 2166136261;
|
|
32
|
+
for (let index = 0; index < slotId.length; index++) {
|
|
33
|
+
hash ^= slotId.charCodeAt(index);
|
|
34
|
+
hash = Math.imul(hash, 16777619);
|
|
35
|
+
}
|
|
36
|
+
const name = meshName.replace(/[^a-zA-Z0-9_-]/g, '_') || 'mesh';
|
|
37
|
+
return `${name}_${(hash >>> 0).toString(36)}`;
|
|
38
|
+
}
|
|
39
|
+
function materialValues(material) {
|
|
40
|
+
const record = materialRecord(material);
|
|
41
|
+
const color = record.color;
|
|
42
|
+
const emissive = record.emissive;
|
|
43
|
+
return {
|
|
44
|
+
color: `#${color?.getHexString() ?? 'ffffff'}`,
|
|
45
|
+
metalness: typeof record.metalness === 'number' ? record.metalness : 0,
|
|
46
|
+
roughness: typeof record.roughness === 'number' ? record.roughness : 1,
|
|
47
|
+
emissive: `#${emissive?.getHexString() ?? '000000'}`,
|
|
48
|
+
opacity: material.opacity,
|
|
49
|
+
mapBlobId: '',
|
|
50
|
+
normalMapBlobId: '',
|
|
51
|
+
roughnessMapBlobId: '',
|
|
52
|
+
metalnessMapBlobId: '',
|
|
53
|
+
emissiveMapBlobId: '',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function cloneModelMaterials(root) {
|
|
57
|
+
root.traverse((object) => {
|
|
58
|
+
if (!(object instanceof Mesh))
|
|
59
|
+
return;
|
|
60
|
+
const clone = (material) => {
|
|
61
|
+
const cloned = material.clone();
|
|
62
|
+
rememberOriginal(cloned);
|
|
63
|
+
return cloned;
|
|
64
|
+
};
|
|
65
|
+
object.material = Array.isArray(object.material)
|
|
66
|
+
? object.material.map(clone)
|
|
67
|
+
: clone(object.material);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function collectMaterialSlots(root) {
|
|
71
|
+
const slots = [];
|
|
72
|
+
const visit = (object, path) => {
|
|
73
|
+
if (object instanceof Mesh) {
|
|
74
|
+
const materials = Array.isArray(object.material)
|
|
75
|
+
? object.material
|
|
76
|
+
: [object.material];
|
|
77
|
+
materials.forEach((material, materialIndex) => {
|
|
78
|
+
rememberOriginal(material);
|
|
79
|
+
const slotId = `${path.join('/') || 'root'}:${materialIndex}`;
|
|
80
|
+
slots.push({
|
|
81
|
+
slotId,
|
|
82
|
+
slotKey: slotKey(slotId, object.name),
|
|
83
|
+
meshName: object.name,
|
|
84
|
+
materialName: material.name,
|
|
85
|
+
values: materialValues(material),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
object.children.forEach((child, index) => visit(child, [...path, index]));
|
|
90
|
+
};
|
|
91
|
+
visit(root, []);
|
|
92
|
+
return slots;
|
|
93
|
+
}
|
|
94
|
+
function bindings(root, slots) {
|
|
95
|
+
const byId = new Map(slots.map((slot) => [slot.slotId, slot]));
|
|
96
|
+
const result = [];
|
|
97
|
+
const visit = (object, path) => {
|
|
98
|
+
if (object instanceof Mesh) {
|
|
99
|
+
const materials = Array.isArray(object.material)
|
|
100
|
+
? object.material
|
|
101
|
+
: [object.material];
|
|
102
|
+
materials.forEach((material, materialIndex) => {
|
|
103
|
+
const slot = byId.get(`${path.join('/') || 'root'}:${materialIndex}`);
|
|
104
|
+
if (slot)
|
|
105
|
+
result.push({ slot, material });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
object.children.forEach((child, index) => visit(child, [...path, index]));
|
|
109
|
+
};
|
|
110
|
+
visit(root, []);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
export function applyMaterialScalars(root, slots, overrides) {
|
|
114
|
+
for (const { slot, material } of bindings(root, slots)) {
|
|
115
|
+
const values = overrides[slot.slotKey];
|
|
116
|
+
if (!values)
|
|
117
|
+
continue;
|
|
118
|
+
const record = materialRecord(material);
|
|
119
|
+
if (values.color !== undefined) {
|
|
120
|
+
;
|
|
121
|
+
record.color?.set(values.color);
|
|
122
|
+
}
|
|
123
|
+
if (values.emissive !== undefined) {
|
|
124
|
+
;
|
|
125
|
+
record.emissive?.set(values.emissive);
|
|
126
|
+
}
|
|
127
|
+
for (const property of ['metalness', 'roughness']) {
|
|
128
|
+
const value = values[property];
|
|
129
|
+
if (value !== undefined && typeof record[property] === 'number') {
|
|
130
|
+
record[property] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (values.opacity !== undefined) {
|
|
134
|
+
material.opacity = values.opacity;
|
|
135
|
+
const original = rememberOriginal(material);
|
|
136
|
+
const transparent = original.transparent || values.opacity < 1;
|
|
137
|
+
if (material.transparent !== transparent) {
|
|
138
|
+
material.transparent = transparent;
|
|
139
|
+
material.needsUpdate = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function scalarConfig(values) {
|
|
145
|
+
return {
|
|
146
|
+
color: t.string(values.color),
|
|
147
|
+
metalness: t.number(values.metalness, { range: [0, 1] }),
|
|
148
|
+
roughness: t.number(values.roughness, { range: [0, 1] }),
|
|
149
|
+
emissive: t.string(values.emissive),
|
|
150
|
+
opacity: t.number(values.opacity, { range: [0, 1] }),
|
|
151
|
+
mapBlobId: t.string(''),
|
|
152
|
+
normalMapBlobId: t.string(''),
|
|
153
|
+
roughnessMapBlobId: t.string(''),
|
|
154
|
+
metalnessMapBlobId: t.string(''),
|
|
155
|
+
emissiveMapBlobId: t.string(''),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function createMaterialOverridesConfig(slots) {
|
|
159
|
+
const cached = configCache.get(slots);
|
|
160
|
+
if (cached)
|
|
161
|
+
return cached;
|
|
162
|
+
const config = t.compound(Object.fromEntries(slots.map((slot) => [
|
|
163
|
+
slot.slotKey,
|
|
164
|
+
t.compound(scalarConfig(slot.values), {
|
|
165
|
+
label: `${slot.meshName || 'Mesh'} / ${slot.materialName || 'Material'}`,
|
|
166
|
+
}),
|
|
167
|
+
])), { label: 'Materials' });
|
|
168
|
+
configCache.set(slots, config);
|
|
169
|
+
return config;
|
|
170
|
+
}
|
|
171
|
+
export function createMaterialTextureController(options) {
|
|
172
|
+
let generation = 0;
|
|
173
|
+
let disposed = false;
|
|
174
|
+
const loaded = new Map();
|
|
175
|
+
const disposeEntry = (material, property) => {
|
|
176
|
+
const entry = loaded.get(material)?.[property];
|
|
177
|
+
if (!entry)
|
|
178
|
+
return;
|
|
179
|
+
entry.texture.dispose();
|
|
180
|
+
delete loaded.get(material)[property];
|
|
181
|
+
};
|
|
182
|
+
const updateTexture = async (material, property, blobId, token) => {
|
|
183
|
+
const original = rememberOriginal(material);
|
|
184
|
+
const record = materialRecord(material);
|
|
185
|
+
if (!blobId) {
|
|
186
|
+
const originalMap = original.maps[property] ?? null;
|
|
187
|
+
if (record[property] !== originalMap ||
|
|
188
|
+
loaded.get(material)?.[property]) {
|
|
189
|
+
material.needsUpdate = true;
|
|
190
|
+
}
|
|
191
|
+
record[property] = originalMap;
|
|
192
|
+
disposeEntry(material, property);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (loaded.get(material)?.[property]?.blobId === blobId)
|
|
196
|
+
return;
|
|
197
|
+
const url = await options.getBlobUrl(blobId);
|
|
198
|
+
const texture = await options.loadTexture(url);
|
|
199
|
+
if (disposed || token !== generation) {
|
|
200
|
+
texture.dispose();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
texture.colorSpace =
|
|
204
|
+
property === 'map' || property === 'emissiveMap'
|
|
205
|
+
? SRGBColorSpace
|
|
206
|
+
: texture.colorSpace;
|
|
207
|
+
disposeEntry(material, property);
|
|
208
|
+
record[property] = texture;
|
|
209
|
+
material.needsUpdate = true;
|
|
210
|
+
const entries = loaded.get(material) ?? {};
|
|
211
|
+
entries[property] = { blobId, texture };
|
|
212
|
+
loaded.set(material, entries);
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
async update(root, slots, overrides) {
|
|
216
|
+
const token = ++generation;
|
|
217
|
+
const tasks = [];
|
|
218
|
+
for (const { slot, material } of bindings(root, slots)) {
|
|
219
|
+
const values = overrides[slot.slotKey];
|
|
220
|
+
if (!values)
|
|
221
|
+
continue;
|
|
222
|
+
for (const [blobProperty, property] of textureProperties) {
|
|
223
|
+
const blobId = values[blobProperty];
|
|
224
|
+
if (blobId === undefined)
|
|
225
|
+
continue;
|
|
226
|
+
tasks.push(updateTexture(material, property, blobId, token).catch((error) => {
|
|
227
|
+
if (!disposed && token === generation) {
|
|
228
|
+
if (options.onError) {
|
|
229
|
+
options.onError(error, slot.slotId, blobProperty);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.warn(`[materials] 贴图加载失败: ${slot.slotId}/${blobProperty}`, error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await Promise.all(tasks);
|
|
239
|
+
if (!disposed && token === generation)
|
|
240
|
+
options.invalidate();
|
|
241
|
+
},
|
|
242
|
+
dispose() {
|
|
243
|
+
disposed = true;
|
|
244
|
+
generation++;
|
|
245
|
+
for (const [material, entries] of loaded) {
|
|
246
|
+
for (const property of Object.keys(entries)) {
|
|
247
|
+
disposeEntry(material, property);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
loaded.clear();
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modelMaterials.test.d.ts","sourceRoot":"","sources":["../../src/materials/modelMaterials.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Texture } from 'three';
|
|
2
|
+
import { SkeletonUtils } from 'three-stdlib';
|
|
3
|
+
import { applyMaterialScalars, cloneModelMaterials, collectMaterialSlots, createMaterialOverridesConfig, createMaterialTextureController, } from './modelMaterials';
|
|
4
|
+
import { disposeObjectTree } from '../assetLibrary/dispose';
|
|
5
|
+
function modelWithTwoMeshes() {
|
|
6
|
+
const root = new Group();
|
|
7
|
+
const a = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
|
|
8
|
+
const b = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
|
|
9
|
+
a.name = 'Body';
|
|
10
|
+
b.name = 'Body';
|
|
11
|
+
root.add(a, b);
|
|
12
|
+
return { root, a, b };
|
|
13
|
+
}
|
|
14
|
+
function values(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
color: '#ffffff',
|
|
17
|
+
metalness: 0,
|
|
18
|
+
roughness: 1,
|
|
19
|
+
emissive: '#000000',
|
|
20
|
+
opacity: 1,
|
|
21
|
+
mapBlobId: '',
|
|
22
|
+
normalMapBlobId: '',
|
|
23
|
+
roughnessMapBlobId: '',
|
|
24
|
+
metalnessMapBlobId: '',
|
|
25
|
+
emissiveMapBlobId: '',
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
test('stable slot keys survive traversal and duplicate mesh names', () => {
|
|
30
|
+
const { root } = modelWithTwoMeshes();
|
|
31
|
+
const slots = collectMaterialSlots(root);
|
|
32
|
+
expect(slots).toHaveLength(2);
|
|
33
|
+
expect(slots[0].slotId).not.toBe(slots[1].slotId);
|
|
34
|
+
expect(slots[0].slotKey).not.toBe(slots[1].slotKey);
|
|
35
|
+
expect(collectMaterialSlots(root)).toEqual(slots);
|
|
36
|
+
});
|
|
37
|
+
test('SkeletonUtils.clone instances receive isolated materials and scalar updates stay instance-local', () => {
|
|
38
|
+
const { root } = modelWithTwoMeshes();
|
|
39
|
+
const first = SkeletonUtils.clone(root);
|
|
40
|
+
const second = SkeletonUtils.clone(root);
|
|
41
|
+
cloneModelMaterials(first);
|
|
42
|
+
cloneModelMaterials(second);
|
|
43
|
+
const firstSlots = collectMaterialSlots(first);
|
|
44
|
+
const secondSlots = collectMaterialSlots(second);
|
|
45
|
+
const firstMesh = first.children[0];
|
|
46
|
+
const secondMesh = second.children[0];
|
|
47
|
+
const firstMaterial = firstMesh.material;
|
|
48
|
+
const secondMaterial = secondMesh.material;
|
|
49
|
+
expect(firstMaterial).not.toBe(secondMaterial);
|
|
50
|
+
expect(firstMaterial).not.toBe(root.children[0].material);
|
|
51
|
+
applyMaterialScalars(first, firstSlots, {
|
|
52
|
+
[firstSlots[0].slotKey]: values({ roughness: 0.2, color: '#112233' }),
|
|
53
|
+
});
|
|
54
|
+
expect(firstMaterial.roughness).toBe(0.2);
|
|
55
|
+
expect(firstMaterial.color.getHexString()).toBe('112233');
|
|
56
|
+
expect(secondMaterial.roughness).toBe(1);
|
|
57
|
+
expect(secondMaterial.color.getHexString()).toBe('ffffff');
|
|
58
|
+
});
|
|
59
|
+
test('Theatre compound configs are cached and preserve slot defaults', () => {
|
|
60
|
+
const { root } = modelWithTwoMeshes();
|
|
61
|
+
const slots = collectMaterialSlots(root);
|
|
62
|
+
const first = createMaterialOverridesConfig(slots);
|
|
63
|
+
const second = createMaterialOverridesConfig(slots);
|
|
64
|
+
expect(second).toBe(first);
|
|
65
|
+
});
|
|
66
|
+
test('texture updates dispose replacements, and empty IDs restore immutable originals', async () => {
|
|
67
|
+
const { root, a } = modelWithTwoMeshes();
|
|
68
|
+
const original = new Texture();
|
|
69
|
+
const sourceMaterial = a.material;
|
|
70
|
+
sourceMaterial.map = original;
|
|
71
|
+
cloneModelMaterials(root);
|
|
72
|
+
const material = a.material;
|
|
73
|
+
const slots = collectMaterialSlots(root);
|
|
74
|
+
const first = new Texture();
|
|
75
|
+
const second = new Texture();
|
|
76
|
+
const firstDispose = jest.spyOn(first, 'dispose');
|
|
77
|
+
const secondDispose = jest.spyOn(second, 'dispose');
|
|
78
|
+
const textures = new Map([
|
|
79
|
+
['blob:first', first],
|
|
80
|
+
['blob:second', second],
|
|
81
|
+
]);
|
|
82
|
+
const invalidate = jest.fn();
|
|
83
|
+
const controller = createMaterialTextureController({
|
|
84
|
+
getBlobUrl: async (blobId) => `url:${blobId}`,
|
|
85
|
+
loadTexture: async (url) => textures.get(url.slice(4)),
|
|
86
|
+
invalidate,
|
|
87
|
+
});
|
|
88
|
+
const initialVersion = material.version;
|
|
89
|
+
await controller.update(root, slots, {
|
|
90
|
+
[slots[0].slotKey]: { mapBlobId: 'blob:first' },
|
|
91
|
+
});
|
|
92
|
+
expect(material.map).toBe(first);
|
|
93
|
+
expect(material.version).toBeGreaterThan(initialVersion);
|
|
94
|
+
await controller.update(root, slots, {
|
|
95
|
+
[slots[0].slotKey]: { mapBlobId: 'blob:second' },
|
|
96
|
+
});
|
|
97
|
+
expect(material.map).toBe(second);
|
|
98
|
+
expect(firstDispose).toHaveBeenCalledTimes(1);
|
|
99
|
+
await controller.update(root, slots, {
|
|
100
|
+
[slots[0].slotKey]: { mapBlobId: '' },
|
|
101
|
+
});
|
|
102
|
+
expect(material.map).toBe(original);
|
|
103
|
+
expect(secondDispose).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(invalidate).toHaveBeenCalled();
|
|
105
|
+
controller.dispose();
|
|
106
|
+
});
|
|
107
|
+
test('texture load failures report the slot and property through onError', async () => {
|
|
108
|
+
const { root } = modelWithTwoMeshes();
|
|
109
|
+
cloneModelMaterials(root);
|
|
110
|
+
const slots = collectMaterialSlots(root);
|
|
111
|
+
const cause = new Error('unsupported image');
|
|
112
|
+
const onError = jest.fn();
|
|
113
|
+
const controller = createMaterialTextureController({
|
|
114
|
+
getBlobUrl: async (blobId) => blobId,
|
|
115
|
+
loadTexture: async () => {
|
|
116
|
+
throw cause;
|
|
117
|
+
},
|
|
118
|
+
onError,
|
|
119
|
+
invalidate: () => { },
|
|
120
|
+
});
|
|
121
|
+
await controller.update(root, slots, {
|
|
122
|
+
[slots[0].slotKey]: { mapBlobId: 'broken-texture' },
|
|
123
|
+
});
|
|
124
|
+
expect(onError).toHaveBeenCalledWith(cause, slots[0].slotId, 'mapBlobId');
|
|
125
|
+
controller.dispose();
|
|
126
|
+
});
|
|
127
|
+
test('stale texture loads cannot overwrite a newer selection and are disposed', async () => {
|
|
128
|
+
const { root, a } = modelWithTwoMeshes();
|
|
129
|
+
cloneModelMaterials(root);
|
|
130
|
+
const slots = collectMaterialSlots(root);
|
|
131
|
+
const stale = new Texture();
|
|
132
|
+
const current = new Texture();
|
|
133
|
+
const staleDispose = jest.spyOn(stale, 'dispose');
|
|
134
|
+
let resolveStale;
|
|
135
|
+
const controller = createMaterialTextureController({
|
|
136
|
+
getBlobUrl: async (blobId) => blobId,
|
|
137
|
+
loadTexture: (url) => url === 'stale'
|
|
138
|
+
? new Promise((resolve) => {
|
|
139
|
+
resolveStale = resolve;
|
|
140
|
+
})
|
|
141
|
+
: Promise.resolve(current),
|
|
142
|
+
invalidate: () => { },
|
|
143
|
+
});
|
|
144
|
+
const staleUpdate = controller.update(root, slots, {
|
|
145
|
+
[slots[0].slotKey]: { mapBlobId: 'stale' },
|
|
146
|
+
});
|
|
147
|
+
await controller.update(root, slots, {
|
|
148
|
+
[slots[0].slotKey]: { mapBlobId: 'current' },
|
|
149
|
+
});
|
|
150
|
+
resolveStale(stale);
|
|
151
|
+
await staleUpdate;
|
|
152
|
+
expect(a.material.map).toBe(current);
|
|
153
|
+
expect(staleDispose).toHaveBeenCalledTimes(1);
|
|
154
|
+
controller.dispose();
|
|
155
|
+
});
|
|
156
|
+
test('placed-instance teardown preserves shared original textures and disposes overrides once', async () => {
|
|
157
|
+
const { root, a, b } = modelWithTwoMeshes();
|
|
158
|
+
const original = new Texture();
|
|
159
|
+
a.material.map = original;
|
|
160
|
+
b.material.map = original;
|
|
161
|
+
const first = SkeletonUtils.clone(root);
|
|
162
|
+
const second = SkeletonUtils.clone(root);
|
|
163
|
+
cloneModelMaterials(first);
|
|
164
|
+
cloneModelMaterials(second);
|
|
165
|
+
const slots = collectMaterialSlots(first);
|
|
166
|
+
const override = new Texture();
|
|
167
|
+
const originalDispose = jest.spyOn(original, 'dispose');
|
|
168
|
+
const overrideDispose = jest.spyOn(override, 'dispose');
|
|
169
|
+
const firstMaterial = first.children[0]
|
|
170
|
+
.material;
|
|
171
|
+
const secondMaterial = second.children[0]
|
|
172
|
+
.material;
|
|
173
|
+
const materialDispose = jest.spyOn(firstMaterial, 'dispose');
|
|
174
|
+
const controller = createMaterialTextureController({
|
|
175
|
+
getBlobUrl: async (blobId) => blobId,
|
|
176
|
+
loadTexture: async () => override,
|
|
177
|
+
invalidate: () => { },
|
|
178
|
+
});
|
|
179
|
+
await controller.update(first, slots, {
|
|
180
|
+
[slots[0].slotKey]: { mapBlobId: 'override' },
|
|
181
|
+
});
|
|
182
|
+
controller.dispose();
|
|
183
|
+
disposeObjectTree(first, { disposeTextures: false });
|
|
184
|
+
expect(originalDispose).not.toHaveBeenCalled();
|
|
185
|
+
expect(secondMaterial.map).toBe(original);
|
|
186
|
+
expect(overrideDispose).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(materialDispose).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type MaterialValues = {
|
|
2
|
+
color: string;
|
|
3
|
+
metalness: number;
|
|
4
|
+
roughness: number;
|
|
5
|
+
emissive: string;
|
|
6
|
+
opacity: number;
|
|
7
|
+
mapBlobId: string;
|
|
8
|
+
normalMapBlobId: string;
|
|
9
|
+
roughnessMapBlobId: string;
|
|
10
|
+
metalnessMapBlobId: string;
|
|
11
|
+
emissiveMapBlobId: string;
|
|
12
|
+
};
|
|
13
|
+
export type MaterialSlot = {
|
|
14
|
+
slotId: string;
|
|
15
|
+
slotKey: string;
|
|
16
|
+
meshName: string;
|
|
17
|
+
materialName: string;
|
|
18
|
+
values: MaterialValues;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/materials/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;IACvB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,kBAAkB,EAAE,MAAM,CAAA;IAC1B,iBAAiB,EAAE,MAAM,CAAA;CAC1B,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,cAAc,CAAA;CACvB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ISheetObject } from '@dreamerbird/core';
|
|
2
|
+
import { type Group } from 'three';
|
|
3
|
+
import type { MaterialSlot } from './types';
|
|
4
|
+
export declare function useModelMaterials({ instance, sheetObject, slots, invalidate, }: {
|
|
5
|
+
instance: Group;
|
|
6
|
+
sheetObject: ISheetObject<any> | undefined;
|
|
7
|
+
slots: MaterialSlot[];
|
|
8
|
+
invalidate(): void;
|
|
9
|
+
}): void;
|
|
10
|
+
//# sourceMappingURL=useModelMaterials.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useModelMaterials.d.ts","sourceRoot":"","sources":["../../src/materials/useModelMaterials.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,mBAAmB,CAAA;AAGnD,OAAO,EAAgB,KAAK,KAAK,EAAC,MAAM,OAAO,CAAA;AAO/C,OAAO,KAAK,EAAC,YAAY,EAAiB,MAAM,SAAS,CAAA;AAEzD,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EACR,WAAW,EACX,KAAK,EACL,UAAU,GACX,EAAE;IACD,QAAQ,EAAE,KAAK,CAAA;IACf,WAAW,EAAE,YAAY,CAAC,GAAG,CAAC,GAAG,SAAS,CAAA;IAC1C,KAAK,EAAE,YAAY,EAAE,CAAA;IACrB,UAAU,IAAI,IAAI,CAAA;CACnB,GAAG,IAAI,CA8BP"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useVal } from '@dreamerbird/react';
|
|
2
|
+
import { useEffect, useMemo } from 'react';
|
|
3
|
+
import { TextureLoader } from 'three';
|
|
4
|
+
import { getLibrary } from '../assetLibrary/library';
|
|
5
|
+
import { LibraryError } from '../assetLibrary/source';
|
|
6
|
+
import { applyMaterialScalars, createMaterialTextureController, } from './modelMaterials';
|
|
7
|
+
export function useModelMaterials({ instance, sheetObject, slots, invalidate, }) {
|
|
8
|
+
const overrides = useVal(sheetObject?.props.materialOverrides) ?? {};
|
|
9
|
+
const controller = useMemo(() => {
|
|
10
|
+
const loader = new TextureLoader();
|
|
11
|
+
return createMaterialTextureController({
|
|
12
|
+
getBlobUrl: (blobId) => getLibrary().getBlobUrl(blobId),
|
|
13
|
+
loadTexture: (url) => loader.loadAsync(url),
|
|
14
|
+
onError: (error, slotId, property) => {
|
|
15
|
+
getLibrary().reportError(new LibraryError('network', `材质贴图加载失败: ${slotId}/${property}`, error));
|
|
16
|
+
},
|
|
17
|
+
invalidate,
|
|
18
|
+
});
|
|
19
|
+
}, [instance, invalidate]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
applyMaterialScalars(instance, slots, overrides);
|
|
22
|
+
invalidate();
|
|
23
|
+
void controller.update(instance, slots, overrides);
|
|
24
|
+
}, [instance, slots, overrides, controller, invalidate]);
|
|
25
|
+
useEffect(() => () => controller.dispose(), [controller]);
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useModelMaterials.test.d.ts","sourceRoot":"","sources":["../../src/materials/useModelMaterials.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
let projectSeq = 0;
|
|
2
|
+
jest.doMock('three', () => {
|
|
3
|
+
const actual = jest.requireActual('three');
|
|
4
|
+
return {
|
|
5
|
+
...actual,
|
|
6
|
+
TextureLoader: class {
|
|
7
|
+
async loadAsync(_url) {
|
|
8
|
+
const texture = new actual.Texture();
|
|
9
|
+
loadedTextures.push(texture);
|
|
10
|
+
return texture;
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
jest.doMock('../assetLibrary/library', () => ({
|
|
16
|
+
...jest.requireActual('../assetLibrary/library'),
|
|
17
|
+
getLibrary: () => ({ getBlobUrl: async (blobId) => blobId }),
|
|
18
|
+
}));
|
|
19
|
+
const loadedTextures = [];
|
|
20
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
21
|
+
const React = require('react');
|
|
22
|
+
const ReactDOM = require('react-dom/client');
|
|
23
|
+
const core = require('@dreamerbird/core');
|
|
24
|
+
const three = require('three');
|
|
25
|
+
const { useModelMaterials } = require('./useModelMaterials');
|
|
26
|
+
const { cloneModelMaterials, collectMaterialSlots, createMaterialOverridesConfig, } = require('./modelMaterials');
|
|
27
|
+
function makeInstance() {
|
|
28
|
+
const instance = new three.Group();
|
|
29
|
+
const mesh = new three.Mesh(new three.BoxGeometry(), new three.MeshStandardMaterial());
|
|
30
|
+
mesh.name = 'Body';
|
|
31
|
+
instance.add(mesh);
|
|
32
|
+
cloneModelMaterials(instance);
|
|
33
|
+
return instance;
|
|
34
|
+
}
|
|
35
|
+
function Harness({ instance, slots, sheetObject, }) {
|
|
36
|
+
useModelMaterials({ instance, slots, sheetObject, invalidate: () => { } });
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
test('instance replacement disposes the previous controller textures', async () => {
|
|
40
|
+
loadedTextures.length = 0;
|
|
41
|
+
const first = makeInstance();
|
|
42
|
+
const second = makeInstance();
|
|
43
|
+
const firstSlots = collectMaterialSlots(first);
|
|
44
|
+
const secondSlots = collectMaterialSlots(second);
|
|
45
|
+
const project = core.getProject(`material-hook-${++projectSeq}`);
|
|
46
|
+
const sheetObject = project.sheet('Scene').object('Foo', {
|
|
47
|
+
materialOverrides: createMaterialOverridesConfig(firstSlots),
|
|
48
|
+
});
|
|
49
|
+
sheetObject.initialValue = {
|
|
50
|
+
materialOverrides: {
|
|
51
|
+
[firstSlots[0].slotKey]: {
|
|
52
|
+
...firstSlots[0].values,
|
|
53
|
+
mapBlobId: 'override',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const element = document.createElement('div');
|
|
58
|
+
const root = ReactDOM.createRoot(element);
|
|
59
|
+
try {
|
|
60
|
+
await React.act(async () => {
|
|
61
|
+
root.render(React.createElement(Harness, { instance: first, slots: firstSlots, sheetObject: sheetObject }));
|
|
62
|
+
});
|
|
63
|
+
const oldTexture = loadedTextures[0];
|
|
64
|
+
const oldDispose = jest.spyOn(oldTexture, 'dispose');
|
|
65
|
+
await React.act(async () => {
|
|
66
|
+
root.render(React.createElement(Harness, { instance: second, slots: secondSlots, sheetObject: sheetObject }));
|
|
67
|
+
});
|
|
68
|
+
expect(loadedTextures).toHaveLength(2);
|
|
69
|
+
expect(oldDispose).toHaveBeenCalledTimes(1);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await React.act(async () => root.unmount());
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
export {};
|
package/dist/poses.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置静态姿势库的公开入口(包根与 `@dreamerbird/camera-studio/poses`
|
|
3
|
+
* 子路径同时导出)。
|
|
4
|
+
*
|
|
5
|
+
* 姿势数据随包编译进 JS,不依赖 IndexedDB / 网络 / React。引用本包的
|
|
6
|
+
* 项目可以直接列出姿势、把 `entry.bones` 写进自己的 THREE 骨架,或交给
|
|
7
|
+
* Agent API 的 `poses.applyCustom`。
|
|
8
|
+
*
|
|
9
|
+
* - id 稳定:`seed-pose-<key>`,与场景里已保存的姿势引用一致。
|
|
10
|
+
* - rig 是内置族代表模型(Y Bot)的 Mixamo 骨架 id;导入同款 Mixamo
|
|
11
|
+
* 模型得到的 rig 与之相等,跨项目可直接复用。
|
|
12
|
+
* - QBX 手势条目只包含手指骨,是叠加片段,不覆盖身体姿势。
|
|
13
|
+
*/
|
|
14
|
+
import { applyPose, captureBindPose, capturePose } from './assetLibrary/pose';
|
|
15
|
+
import type { PoseEntry } from './assetLibrary/types';
|
|
16
|
+
export { applyPose, captureBindPose, capturePose };
|
|
17
|
+
/** 内置姿势所属骨架族(内置 Y Bot 的 rig id) */
|
|
18
|
+
export declare const BUILTIN_POSE_RIG: string;
|
|
19
|
+
/** 内置姿势数据来源,用于署名/展示;不影响运行时行为 */
|
|
20
|
+
export declare const BUILTIN_POSE_SOURCES: readonly [{
|
|
21
|
+
readonly id: "procedural";
|
|
22
|
+
readonly name: "内置程序化基础姿势";
|
|
23
|
+
}, {
|
|
24
|
+
readonly id: "dpl";
|
|
25
|
+
readonly name: "Dynamic Pose Library Free (BlendAtlas)";
|
|
26
|
+
}, {
|
|
27
|
+
readonly id: "qbx";
|
|
28
|
+
readonly name: "QBX Pose Asset Pack";
|
|
29
|
+
}];
|
|
30
|
+
export type BuiltinPoseSourceId = (typeof BUILTIN_POSE_SOURCES)[number]['id'];
|
|
31
|
+
/**
|
|
32
|
+
* 列出全部内置静态姿势。返回的条目是包内共享的只读数据,请勿原地修改;
|
|
33
|
+
* 需要改骨骼时先拷贝 `bones`。
|
|
34
|
+
*/
|
|
35
|
+
export declare function listBuiltinPoses(): PoseEntry[];
|
|
36
|
+
/** 按稳定 id 取内置姿势;未知 id 返回 undefined。 */
|
|
37
|
+
export declare function getBuiltinPose(id: string): PoseEntry | undefined;
|
|
38
|
+
//# sourceMappingURL=poses.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poses.d.ts","sourceRoot":"","sources":["../src/poses.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAC,SAAS,EAAE,eAAe,EAAE,WAAW,EAAC,MAAM,qBAAqB,CAAA;AAK3E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,sBAAsB,CAAA;AAEnD,OAAO,EAAC,SAAS,EAAE,eAAe,EAAE,WAAW,EAAC,CAAA;AAEhD,mCAAmC;AACnC,eAAO,MAAM,gBAAgB,QAAmB,CAAA;AAEhD,gCAAgC;AAChC,eAAO,MAAM,oBAAoB;;;;;;;;;EAIvB,CAAA;AAEV,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,oBAAoB,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAA;AAI7E;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,SAAS,EAAE,CAM9C;AAED,uCAAuC;AACvC,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAGhE"}
|