talking-head-studio 0.4.0 → 0.4.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.
- package/dist/TalkingHeadVisualization.d.ts +1 -1
- package/dist/TalkingHeadVisualization.js +13 -13
- package/dist/editor/AvatarCanvas.d.ts +3 -14
- package/dist/editor/AvatarCanvas.js +5 -4
- package/dist/editor/AvatarEditor.d.ts +1 -0
- package/dist/editor/AvatarEditor.js +6 -0
- package/dist/editor/AvatarEditor.native.d.ts +4 -0
- package/dist/editor/AvatarEditor.native.js +93 -0
- package/dist/editor/boneSnap.d.ts +25 -0
- package/dist/editor/boneSnap.js +93 -0
- package/dist/editor/index.d.ts +4 -0
- package/dist/editor/index.js +13 -1
- package/dist/editor/types.d.ts +18 -4
- package/dist/utils/avatarUtils.d.ts +2 -3
- package/dist/utils/avatarUtils.js +5 -6
- package/dist/wardrobe/wardrobeStore.d.ts +1 -1
- package/dist/wgpu/WgpuAvatar.d.ts +3 -0
- package/dist/wgpu/WgpuAvatar.js +55 -87
- package/dist/{filament → wgpu}/morphTables.js +1 -1
- package/dist/wgpu/useAuthedModelUri.d.ts +11 -0
- package/dist/{filament/useAuthedFilamentUri.js → wgpu/useAuthedModelUri.js} +9 -9
- package/package.json +2 -15
- package/dist/filament/FilamentAvatar.d.ts +0 -41
- package/dist/filament/FilamentAvatar.js +0 -755
- package/dist/filament/editor/FilamentEditor.d.ts +0 -16
- package/dist/filament/editor/FilamentEditor.js +0 -880
- package/dist/filament/editor/FilamentEditor.web.d.ts +0 -19
- package/dist/filament/editor/FilamentEditor.web.js +0 -58
- package/dist/filament/editor/PrecisionPanel.d.ts +0 -1
- package/dist/filament/editor/PrecisionPanel.js +0 -252
- package/dist/filament/editor/boneSnap.d.ts +0 -10
- package/dist/filament/editor/boneSnap.js +0 -97
- package/dist/filament/editor/index.d.ts +0 -5
- package/dist/filament/editor/index.js +0 -19
- package/dist/filament/index.d.ts +0 -6
- package/dist/filament/index.js +0 -24
- package/dist/filament/useAuthedFilamentUri.d.ts +0 -11
- /package/dist/{filament/editor → editor}/studioTheme.d.ts +0 -0
- /package/dist/{filament/editor → editor}/studioTheme.js +0 -0
- /package/dist/{filament → wgpu}/faceSqueezeAssets.d.ts +0 -0
- /package/dist/{filament → wgpu}/faceSqueezeAssets.js +0 -0
- /package/dist/{filament → wgpu}/morphTables.d.ts +0 -0
|
@@ -1,880 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
-
if (mod && mod.__esModule) return mod;
|
|
20
|
-
var result = {};
|
|
21
|
-
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
-
__setModuleDefault(result, mod);
|
|
23
|
-
return result;
|
|
24
|
-
};
|
|
25
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
-
};
|
|
28
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
-
exports.FilamentEditor = void 0;
|
|
30
|
-
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
31
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
32
|
-
const vector_icons_1 = require("@expo/vector-icons");
|
|
33
|
-
const Haptics = __importStar(require("expo-haptics"));
|
|
34
|
-
const react_1 = require("react");
|
|
35
|
-
const react_native_1 = require("react-native");
|
|
36
|
-
const react_native_filament_1 = require("react-native-filament");
|
|
37
|
-
const react_native_gesture_handler_1 = require("react-native-gesture-handler");
|
|
38
|
-
const react_native_reanimated_1 = __importStar(require("react-native-reanimated"));
|
|
39
|
-
const useAuthedFilamentUri_1 = require("../useAuthedFilamentUri");
|
|
40
|
-
const boneSnap_1 = require("./boneSnap");
|
|
41
|
-
const studioApi_1 = require("../../api/studioApi");
|
|
42
|
-
const PrecisionPanel_1 = __importDefault(require("./PrecisionPanel"));
|
|
43
|
-
const studioTheme_1 = require("./studioTheme");
|
|
44
|
-
// Lazy require avoids mixing require/import at module top level (Metro bug).
|
|
45
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
|
|
46
|
-
const useCameraManipulator = require('react-native-filament').useCameraManipulator;
|
|
47
|
-
// Pure JS transform helpers (no 'worklet' directive — avoids Reanimated
|
|
48
|
-
// babel plugin serializing gesture closure variables).
|
|
49
|
-
function applyDragTranslate(startPos, translationX, translationY, sensitivity) {
|
|
50
|
-
return [
|
|
51
|
-
startPos[0] + translationX * sensitivity,
|
|
52
|
-
startPos[1] - translationY * sensitivity,
|
|
53
|
-
startPos[2],
|
|
54
|
-
];
|
|
55
|
-
}
|
|
56
|
-
function applyPinchScale(startScale, scaleFactor) {
|
|
57
|
-
const clamp = (val) => Math.max(0.1, Math.min(val, 10));
|
|
58
|
-
return [
|
|
59
|
-
clamp(startScale[0] * scaleFactor),
|
|
60
|
-
clamp(startScale[1] * scaleFactor),
|
|
61
|
-
clamp(startScale[2] * scaleFactor),
|
|
62
|
-
];
|
|
63
|
-
}
|
|
64
|
-
function applyRotation(startRot, deltaX, deltaY) {
|
|
65
|
-
return [startRot[0] + deltaY, startRot[1] + deltaX, startRot[2]];
|
|
66
|
-
}
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Constants
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
const MAX_SLOTS = 4; // Adreno 612 (Moto G Stylus) GPU runs out of texture memory with >4 large GLBs
|
|
71
|
-
const MAX_ACCESSORY_BYTES = 50 * 1024 * 1024; // 50 MB total budget for accessory GLBs
|
|
72
|
-
const WORLD_RANGE = 4; // total world units the view spans (~4m for a humanoid scene)
|
|
73
|
-
const DEFAULT_VIEW_WIDTH = 390; // fallback for pre-layout
|
|
74
|
-
const COLORS = {
|
|
75
|
-
bg: studioTheme_1.studioTheme.colors.background,
|
|
76
|
-
surface: studioTheme_1.studioTheme.colors.surface,
|
|
77
|
-
card: studioTheme_1.studioTheme.colors.surfaceRaised,
|
|
78
|
-
border: studioTheme_1.studioTheme.colors.border,
|
|
79
|
-
accent: studioTheme_1.studioTheme.colors.accent,
|
|
80
|
-
textPrimary: studioTheme_1.studioTheme.colors.textPrimary,
|
|
81
|
-
textSecondary: studioTheme_1.studioTheme.colors.textSecondary,
|
|
82
|
-
};
|
|
83
|
-
/**
|
|
84
|
-
* Downloads a URL with auth, loads it into Filament, and only renders
|
|
85
|
-
* AuthedModelInner once the file is cached locally.
|
|
86
|
-
*/
|
|
87
|
-
function AuthedModel({ url, onFileSize, ...modelProps }) {
|
|
88
|
-
const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(url);
|
|
89
|
-
const reportedRef = (0, react_1.useRef)(false);
|
|
90
|
-
(0, react_1.useEffect)(() => {
|
|
91
|
-
if (fileResult && !reportedRef.current) {
|
|
92
|
-
reportedRef.current = true;
|
|
93
|
-
onFileSize?.(fileResult.size);
|
|
94
|
-
}
|
|
95
|
-
}, [fileResult, onFileSize]);
|
|
96
|
-
if (!fileResult) {
|
|
97
|
-
console.log('[FilamentEditor] AuthedModel waiting for download:', url.slice(-40));
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
return (0, jsx_runtime_1.jsx)(AuthedModelInner, { localUri: fileResult.uri, ...modelProps });
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Throttled transform updates via setTimeout (max ~5fps during gesture).
|
|
104
|
-
*
|
|
105
|
-
* Previous approaches all crashed on Adreno 612:
|
|
106
|
-
* - React state + useEffect (300 JNI/sec) → SIGABRT (FabricUIManager null)
|
|
107
|
-
* - Choreographer + Mat4 chain → SIGSEGV in Hermes (temp JSI objects freed)
|
|
108
|
-
* - Direct imperative calls → SIGSEGV in libGLESv2_adreno (memcpy race)
|
|
109
|
-
*
|
|
110
|
-
* This approach: gesture handlers write to a ref (zero native calls), a
|
|
111
|
-
* setTimeout at 200ms intervals drains pending values with isValid guards
|
|
112
|
-
* and try/catch. 5 native flushes/sec is safe for Adreno 612.
|
|
113
|
-
*/
|
|
114
|
-
const TRANSFORM_THROTTLE_MS = 200;
|
|
115
|
-
/** Largest axis of a new accessory's bounding box will be clamped to this (world units ≈ metres). */
|
|
116
|
-
const AUTO_NORMALIZE_MAX_SIZE = 0.4;
|
|
117
|
-
function AuthedModelInner({ localUri, initialTranslate, initialRotate, initialScale, onPress, onLoaded, onTransformReady, autoNormalizeMaxSize, boneWorldPos, onAutoNormalized, }) {
|
|
118
|
-
const model = (0, react_native_filament_1.useModel)({ uri: localUri });
|
|
119
|
-
const { transformManager } = (0, react_native_filament_1.useFilamentContext)();
|
|
120
|
-
const notifiedRef = (0, react_1.useRef)(false);
|
|
121
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
122
|
-
const entityRef = (0, react_1.useRef)(undefined);
|
|
123
|
-
const pendingTransform = (0, react_1.useRef)(null);
|
|
124
|
-
const pendingDirty = (0, react_1.useRef)(false);
|
|
125
|
-
const throttleTimer = (0, react_1.useRef)(null);
|
|
126
|
-
const unmounted = (0, react_1.useRef)(false);
|
|
127
|
-
// Apply pending transform with safety guards
|
|
128
|
-
const flushTransform = (0, react_1.useCallback)(() => {
|
|
129
|
-
if (unmounted.current)
|
|
130
|
-
return;
|
|
131
|
-
const entity = entityRef.current;
|
|
132
|
-
if (!entity || !pendingDirty.current)
|
|
133
|
-
return;
|
|
134
|
-
const xform = pendingTransform.current;
|
|
135
|
-
if (!xform)
|
|
136
|
-
return;
|
|
137
|
-
if (!transformManager.isValid)
|
|
138
|
-
return;
|
|
139
|
-
pendingDirty.current = false;
|
|
140
|
-
try {
|
|
141
|
-
transformManager.setEntityScale(entity, xform.s, false);
|
|
142
|
-
transformManager.setEntityRotation(entity, xform.r[0], [1, 0, 0], true);
|
|
143
|
-
transformManager.setEntityRotation(entity, xform.r[1], [0, 1, 0], true);
|
|
144
|
-
transformManager.setEntityRotation(entity, xform.r[2], [0, 0, 1], true);
|
|
145
|
-
transformManager.setEntityPosition(entity, xform.t, true);
|
|
146
|
-
}
|
|
147
|
-
catch (e) {
|
|
148
|
-
console.warn('[FilamentEditor] Transform failed (native object may be freed):', e);
|
|
149
|
-
}
|
|
150
|
-
}, [transformManager]);
|
|
151
|
-
// Queue a transform — writes to ref, schedules throttled native flush
|
|
152
|
-
const queueTransform = (0, react_1.useCallback)((t, r, s) => {
|
|
153
|
-
pendingTransform.current = { t, r, s };
|
|
154
|
-
pendingDirty.current = true;
|
|
155
|
-
// Deduplicated throttle: one timer at a time
|
|
156
|
-
if (!throttleTimer.current && !unmounted.current) {
|
|
157
|
-
throttleTimer.current = setTimeout(() => {
|
|
158
|
-
throttleTimer.current = null;
|
|
159
|
-
flushTransform();
|
|
160
|
-
}, TRANSFORM_THROTTLE_MS);
|
|
161
|
-
}
|
|
162
|
-
}, [flushTransform]);
|
|
163
|
-
// Cleanup on unmount
|
|
164
|
-
(0, react_1.useEffect)(() => {
|
|
165
|
-
unmounted.current = false;
|
|
166
|
-
return () => {
|
|
167
|
-
unmounted.current = true;
|
|
168
|
-
if (throttleTimer.current) {
|
|
169
|
-
clearTimeout(throttleTimer.current);
|
|
170
|
-
throttleTimer.current = null;
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
}, []);
|
|
174
|
-
// Once loaded: apply initial transform (with optional auto-normalization), notify parent
|
|
175
|
-
(0, react_1.useEffect)(() => {
|
|
176
|
-
if (model.state !== 'loaded')
|
|
177
|
-
return;
|
|
178
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
179
|
-
entityRef.current = model.rootEntity;
|
|
180
|
-
if (!notifiedRef.current) {
|
|
181
|
-
notifiedRef.current = true;
|
|
182
|
-
let t = initialTranslate ?? [0, 0, 0];
|
|
183
|
-
const r = initialRotate ?? [0, 0, 0];
|
|
184
|
-
let s = initialScale ?? [1, 1, 1];
|
|
185
|
-
// Auto-normalize: scale model so its largest AABB dimension ≤ autoNormalizeMaxSize,
|
|
186
|
-
// then center it on the bone position.
|
|
187
|
-
// In Filament TRS order: world_vertex = position + scale * local_vertex
|
|
188
|
-
// So to put the bbox center at bone pos: position = bonePos - center * scale
|
|
189
|
-
const bb = model.boundingBox;
|
|
190
|
-
if (autoNormalizeMaxSize && bb) {
|
|
191
|
-
const { center, halfExtent } = bb;
|
|
192
|
-
const minY = center[1] - halfExtent[1]; // bottom of bbox in local space
|
|
193
|
-
const maxExtent = Math.max(halfExtent[0], halfExtent[1], halfExtent[2]) * 2;
|
|
194
|
-
if (maxExtent > 0) {
|
|
195
|
-
const uniformScale = Math.min(autoNormalizeMaxSize / maxExtent, 1.0);
|
|
196
|
-
s = [uniformScale, uniformScale, uniformScale];
|
|
197
|
-
const bone = boneWorldPos ?? [0, 0, 0];
|
|
198
|
-
t = [
|
|
199
|
-
bone[0] - center[0] * uniformScale,
|
|
200
|
-
bone[1] - minY * uniformScale,
|
|
201
|
-
bone[2] - center[2] * uniformScale,
|
|
202
|
-
];
|
|
203
|
-
const relOffset = [
|
|
204
|
-
-center[0] * uniformScale,
|
|
205
|
-
-minY * uniformScale,
|
|
206
|
-
-center[2] * uniformScale,
|
|
207
|
-
];
|
|
208
|
-
console.log(`[FilamentEditor] Auto-normalized: maxExtent=${maxExtent.toFixed(3)} → scale=${uniformScale.toFixed(4)}`);
|
|
209
|
-
onAutoNormalized?.(relOffset, uniformScale);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
pendingTransform.current = { t, r, s };
|
|
213
|
-
pendingDirty.current = true;
|
|
214
|
-
flushTransform();
|
|
215
|
-
onTransformReady?.(queueTransform);
|
|
216
|
-
onLoaded?.();
|
|
217
|
-
}
|
|
218
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
219
|
-
}, [model.state]);
|
|
220
|
-
if (model.state === 'loading')
|
|
221
|
-
return null;
|
|
222
|
-
const MR = react_native_filament_1.ModelRenderer;
|
|
223
|
-
return (0, jsx_runtime_1.jsx)(MR, { model: model, onPress: onPress });
|
|
224
|
-
}
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// AvatarModel — loads avatar and signals when ready (no transforms needed)
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
const SKIN_MATERIAL_KEYWORDS = ['skin', 'body', 'flesh', 'face', 'head'];
|
|
229
|
-
function hexToFloat4(hex) {
|
|
230
|
-
const cleaned = hex.replace('#', '');
|
|
231
|
-
const r = parseInt(cleaned.slice(0, 2), 16) / 255;
|
|
232
|
-
const g = parseInt(cleaned.slice(2, 4), 16) / 255;
|
|
233
|
-
const b = parseInt(cleaned.slice(4, 6), 16) / 255;
|
|
234
|
-
return [r, g, b, 1.0];
|
|
235
|
-
}
|
|
236
|
-
function AvatarModel({ uri, onLoaded, skinColor }) {
|
|
237
|
-
const model = (0, react_native_filament_1.useModel)({ uri });
|
|
238
|
-
const didNotify = (0, react_1.useRef)(false);
|
|
239
|
-
const { renderableManager } = (0, react_native_filament_1.useFilamentContext)();
|
|
240
|
-
(0, react_1.useEffect)(() => {
|
|
241
|
-
if (model.state === 'loaded' && !didNotify.current) {
|
|
242
|
-
didNotify.current = true;
|
|
243
|
-
onLoaded();
|
|
244
|
-
}
|
|
245
|
-
}, [model.state, onLoaded]);
|
|
246
|
-
(0, react_1.useEffect)(() => {
|
|
247
|
-
if (model.state !== 'loaded' || !skinColor)
|
|
248
|
-
return;
|
|
249
|
-
const color = hexToFloat4(skinColor);
|
|
250
|
-
const entities = model.asset.getRenderableEntities();
|
|
251
|
-
const skinEntities = [];
|
|
252
|
-
// First pass: collect entities with skin-named materials
|
|
253
|
-
for (const entity of entities) {
|
|
254
|
-
const count = renderableManager.getPrimitiveCount(entity);
|
|
255
|
-
for (let i = 0; i < count; i++) {
|
|
256
|
-
const mi = renderableManager.getMaterialInstanceAt(entity, i);
|
|
257
|
-
const lower = (mi.name ?? '').toLowerCase();
|
|
258
|
-
if (SKIN_MATERIAL_KEYWORDS.some((k) => lower.includes(k))) {
|
|
259
|
-
mi.setFloat4Parameter('baseColorFactor', color);
|
|
260
|
-
skinEntities.push(entity);
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
// Fallback: if no skin-named materials found, tint everything
|
|
266
|
-
if (skinEntities.length === 0) {
|
|
267
|
-
for (const entity of entities) {
|
|
268
|
-
const count = renderableManager.getPrimitiveCount(entity);
|
|
269
|
-
for (let i = 0; i < count; i++) {
|
|
270
|
-
const mi = renderableManager.getMaterialInstanceAt(entity, i);
|
|
271
|
-
mi.setFloat4Parameter('baseColorFactor', color);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}, [model.state, skinColor, renderableManager]);
|
|
276
|
-
if (model.state === 'loading')
|
|
277
|
-
return null;
|
|
278
|
-
const MR = react_native_filament_1.ModelRenderer;
|
|
279
|
-
return (0, jsx_runtime_1.jsx)(MR, { model: model, castShadow: true, receiveShadow: true });
|
|
280
|
-
}
|
|
281
|
-
// ---------------------------------------------------------------------------
|
|
282
|
-
// Outer component — provides <FilamentScene> context
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
|
-
function FilamentEditor(props) {
|
|
285
|
-
return ((0, jsx_runtime_1.jsx)(react_native_filament_1.FilamentScene, { children: (0, jsx_runtime_1.jsx)(FilamentEditorContent, { ...props }) }));
|
|
286
|
-
}
|
|
287
|
-
exports.FilamentEditor = FilamentEditor;
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
// Inner component — has access to Filament context
|
|
290
|
-
// ---------------------------------------------------------------------------
|
|
291
|
-
function FilamentEditorContent({ avatarUrl, style, onDone, skinColor, equipped, placements, activeAssetId, onActiveAssetChange, onPlacementChange, }) {
|
|
292
|
-
// Pre-download avatar GLB with auth (Filament's native fetcher doesn't send auth headers)
|
|
293
|
-
const avatarFile = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(avatarUrl);
|
|
294
|
-
const localAvatarUri = avatarFile?.uri ?? null;
|
|
295
|
-
// Track whether the avatar model has been loaded by Filament's native engine.
|
|
296
|
-
// Accessories must NOT mount until the avatar is ready — loading two models
|
|
297
|
-
// concurrently causes a null-pointer crash in the Adreno GPU driver's
|
|
298
|
-
// SurfaceTexture thread (SEGV_MAPERR in libGLESv2_adreno.so → memcpy).
|
|
299
|
-
const [avatarReady, setAvatarReady] = (0, react_1.useState)(false);
|
|
300
|
-
(0, react_1.useEffect)(() => {
|
|
301
|
-
console.log('[FilamentEditor] avatarUrl:', avatarUrl);
|
|
302
|
-
console.log('[FilamentEditor] localAvatarUri:', localAvatarUri);
|
|
303
|
-
console.log('[FilamentEditor] equipped slots:', Object.keys(equipped).length);
|
|
304
|
-
}, [avatarUrl, localAvatarUri, equipped]);
|
|
305
|
-
// -------------------------------------------------------------------------
|
|
306
|
-
// Local state
|
|
307
|
-
// -------------------------------------------------------------------------
|
|
308
|
-
const [showPrecision, setShowPrecision] = (0, react_1.useState)(false);
|
|
309
|
-
const [viewWidth, setViewWidth] = (0, react_1.useState)(DEFAULT_VIEW_WIDTH);
|
|
310
|
-
const sensitivity = (0, react_1.useMemo)(() => WORLD_RANGE / viewWidth, [viewWidth]);
|
|
311
|
-
// -------------------------------------------------------------------------
|
|
312
|
-
// Camera
|
|
313
|
-
// -------------------------------------------------------------------------
|
|
314
|
-
const manipulator = useCameraManipulator({
|
|
315
|
-
orbitHomePosition: [0, 1.6, 1.8],
|
|
316
|
-
targetPosition: [0, 1.5, 0],
|
|
317
|
-
});
|
|
318
|
-
// -------------------------------------------------------------------------
|
|
319
|
-
// Transform state — refs only (no React state for transforms)
|
|
320
|
-
//
|
|
321
|
-
// Transforms are applied imperatively via slotTransformApply refs.
|
|
322
|
-
// React state is NOT used for per-frame transform updates during gesture —
|
|
323
|
-
// that pattern causes 300+ JNI calls/second → FabricUIManager null crash.
|
|
324
|
-
// -------------------------------------------------------------------------
|
|
325
|
-
// Refs: current transform values (synchronous read/write in gesture handlers)
|
|
326
|
-
const positionRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [0, 0, 0]));
|
|
327
|
-
const rotationRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [0, 0, 0]));
|
|
328
|
-
const scaleRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [1, 1, 1]));
|
|
329
|
-
// Imperative transform handles: set by AuthedModelInner once model is loaded
|
|
330
|
-
const slotTransformApply = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => null));
|
|
331
|
-
// Slots that have no saved placement yet — eligible for bounding-box auto-normalization
|
|
332
|
-
const newAccessorySlots = (0, react_1.useRef)(new Set());
|
|
333
|
-
// Apply transforms directly — no setState, no re-render, no useEffect
|
|
334
|
-
const applySlotTransform = (0, react_1.useCallback)((slotIdx, pos, rot, scl) => {
|
|
335
|
-
positionRefs.current[slotIdx] = pos;
|
|
336
|
-
rotationRefs.current[slotIdx] = rot;
|
|
337
|
-
scaleRefs.current[slotIdx] = scl;
|
|
338
|
-
slotTransformApply.current[slotIdx]?.(pos, rot, scl);
|
|
339
|
-
}, []);
|
|
340
|
-
// Selection pulse animation (Reanimated for UI animations)
|
|
341
|
-
const selectionScale = (0, react_native_reanimated_1.useSharedValue)(1);
|
|
342
|
-
// -------------------------------------------------------------------------
|
|
343
|
-
// Map equipped assets → fixed slot indices
|
|
344
|
-
// -------------------------------------------------------------------------
|
|
345
|
-
const slotMappings = (0, react_1.useMemo)(() => {
|
|
346
|
-
const assets = Object.values(equipped).filter(Boolean);
|
|
347
|
-
if (assets.length > MAX_SLOTS) {
|
|
348
|
-
console.warn(`[FilamentEditor] ${assets.length} accessories equipped but max is ${MAX_SLOTS} for this GPU. ` +
|
|
349
|
-
`${assets.length - MAX_SLOTS} will not render in the editor.`);
|
|
350
|
-
}
|
|
351
|
-
return assets.slice(0, MAX_SLOTS).map((asset) => ({
|
|
352
|
-
assetId: asset.id,
|
|
353
|
-
asset,
|
|
354
|
-
}));
|
|
355
|
-
}, [equipped]);
|
|
356
|
-
// Reverse lookup: assetId → slot index
|
|
357
|
-
const assetIdToSlot = (0, react_1.useMemo)(() => {
|
|
358
|
-
const map = {};
|
|
359
|
-
slotMappings.forEach((m, i) => {
|
|
360
|
-
map[m.assetId] = i;
|
|
361
|
-
});
|
|
362
|
-
return map;
|
|
363
|
-
}, [slotMappings]);
|
|
364
|
-
// -------------------------------------------------------------------------
|
|
365
|
-
// Sync prop placements → transform refs (only on equip/unequip, not every
|
|
366
|
-
// placement update — gestures write refs directly, so re-syncing on every
|
|
367
|
-
// onPlacementChange would cause a circular update and stutter)
|
|
368
|
-
// -------------------------------------------------------------------------
|
|
369
|
-
(0, react_1.useEffect)(() => {
|
|
370
|
-
for (let i = 0; i < slotMappings.length; i++) {
|
|
371
|
-
const { assetId, asset } = slotMappings[i];
|
|
372
|
-
const p = placements[assetId];
|
|
373
|
-
if (p) {
|
|
374
|
-
const bonePos = boneSnap_1.HUMANOID_BONES[p.bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
375
|
-
const worldPos = [
|
|
376
|
-
p.position[0] + bonePos[0],
|
|
377
|
-
p.position[1] + bonePos[1],
|
|
378
|
-
p.position[2] + bonePos[2],
|
|
379
|
-
];
|
|
380
|
-
positionRefs.current[i] = worldPos;
|
|
381
|
-
rotationRefs.current[i] = p.rotation;
|
|
382
|
-
scaleRefs.current[i] = [p.scale, p.scale, p.scale];
|
|
383
|
-
}
|
|
384
|
-
else {
|
|
385
|
-
// Auto-snap: place new accessories at their attach_bone position
|
|
386
|
-
const boneName = asset.attach_bone ?? 'Head';
|
|
387
|
-
const bonePos = boneSnap_1.HUMANOID_BONES[boneName] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
388
|
-
positionRefs.current[i] = bonePos;
|
|
389
|
-
rotationRefs.current[i] = [0, 0, 0];
|
|
390
|
-
scaleRefs.current[i] = [1, 1, 1];
|
|
391
|
-
// Mark for auto-normalization (bounding box read after GLB loads)
|
|
392
|
-
newAccessorySlots.current.add(i);
|
|
393
|
-
// Persist the initial placement via prop callback
|
|
394
|
-
onPlacementChange(assetId, {
|
|
395
|
-
bone: boneName,
|
|
396
|
-
position: [0, 0, 0],
|
|
397
|
-
rotation: [0, 0, 0],
|
|
398
|
-
scale: 1,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
403
|
-
}, [slotMappings]);
|
|
404
|
-
// -------------------------------------------------------------------------
|
|
405
|
-
// React to prop placement changes (from PrecisionPanel sliders) and sync
|
|
406
|
-
// them to the 3D scene so the model updates live.
|
|
407
|
-
// Skipped during active gestures (gesture handlers update refs directly).
|
|
408
|
-
// -------------------------------------------------------------------------
|
|
409
|
-
const isGestureActive = (0, react_1.useRef)(false);
|
|
410
|
-
// Track prev placements to diff — only sync changed entries
|
|
411
|
-
const prevPlacementsRef = (0, react_1.useRef)(placements);
|
|
412
|
-
(0, react_1.useEffect)(() => {
|
|
413
|
-
if (isGestureActive.current) {
|
|
414
|
-
prevPlacementsRef.current = placements;
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
const prevPlacements = prevPlacementsRef.current;
|
|
418
|
-
prevPlacementsRef.current = placements;
|
|
419
|
-
if (placements === prevPlacements)
|
|
420
|
-
return;
|
|
421
|
-
for (let i = 0; i < slotMappings.length; i++) {
|
|
422
|
-
const { assetId } = slotMappings[i];
|
|
423
|
-
const p = placements[assetId];
|
|
424
|
-
const prev = prevPlacements[assetId];
|
|
425
|
-
if (!p || p === prev)
|
|
426
|
-
continue;
|
|
427
|
-
const bonePos = boneSnap_1.HUMANOID_BONES[p.bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
428
|
-
const worldPos = [
|
|
429
|
-
p.position[0] + bonePos[0],
|
|
430
|
-
p.position[1] + bonePos[1],
|
|
431
|
-
p.position[2] + bonePos[2],
|
|
432
|
-
];
|
|
433
|
-
positionRefs.current[i] = worldPos;
|
|
434
|
-
rotationRefs.current[i] = p.rotation;
|
|
435
|
-
scaleRefs.current[i] = [p.scale, p.scale, p.scale];
|
|
436
|
-
applySlotTransform(i, worldPos, p.rotation, [p.scale, p.scale, p.scale]);
|
|
437
|
-
}
|
|
438
|
-
}, [placements, slotMappings, applySlotTransform]);
|
|
439
|
-
// -------------------------------------------------------------------------
|
|
440
|
-
// Gesture start snapshots (stored in refs for worklet access)
|
|
441
|
-
// -------------------------------------------------------------------------
|
|
442
|
-
const gestureStartPos = (0, react_1.useRef)([0, 0, 0]);
|
|
443
|
-
const gestureStartScale = (0, react_1.useRef)([1, 1, 1]);
|
|
444
|
-
const gestureStartRot = (0, react_1.useRef)([0, 0, 0]);
|
|
445
|
-
const isTouchingAccessory = (0, react_1.useRef)(false);
|
|
446
|
-
const hitScaleBound = (0, react_1.useRef)(false);
|
|
447
|
-
// Pending commit: written during gesture updates so commitActiveTransform
|
|
448
|
-
// doesn't need to read from positionRefs/rotationRefs/scaleRefs (which may
|
|
449
|
-
// be frozen by Reanimated's babel plugin serialization).
|
|
450
|
-
const pendingCommit = (0, react_1.useRef)(null);
|
|
451
|
-
// -------------------------------------------------------------------------
|
|
452
|
-
// Helpers
|
|
453
|
-
// -------------------------------------------------------------------------
|
|
454
|
-
const onLayout = (0, react_1.useCallback)((e) => {
|
|
455
|
-
setViewWidth(e.nativeEvent.layout.width);
|
|
456
|
-
}, []);
|
|
457
|
-
const selectAccessory = (0, react_1.useCallback)((assetId) => {
|
|
458
|
-
onActiveAssetChange(assetId);
|
|
459
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
460
|
-
// Selection pulse
|
|
461
|
-
selectionScale.value = (0, react_native_reanimated_1.withSpring)(1.05, { damping: 8, stiffness: 300 }, () => {
|
|
462
|
-
selectionScale.value = (0, react_native_reanimated_1.withSpring)(1, { damping: 12, stiffness: 200 });
|
|
463
|
-
});
|
|
464
|
-
}, [onActiveAssetChange, selectionScale]);
|
|
465
|
-
const deselectAccessory = (0, react_1.useCallback)(() => {
|
|
466
|
-
onActiveAssetChange(null);
|
|
467
|
-
setShowPrecision(false);
|
|
468
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
469
|
-
}, [onActiveAssetChange]);
|
|
470
|
-
const commitActiveTransform = (0, react_1.useCallback)(() => {
|
|
471
|
-
const commit = pendingCommit.current;
|
|
472
|
-
if (!commit)
|
|
473
|
-
return;
|
|
474
|
-
// Use the current placement from props to get the bone (read from prop — up-to-date)
|
|
475
|
-
const currentPlacement = placements[commit.assetId];
|
|
476
|
-
const bone = currentPlacement?.bone ?? 'Head';
|
|
477
|
-
const bonePos = boneSnap_1.HUMANOID_BONES[bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
478
|
-
const relativePos = [
|
|
479
|
-
commit.pos[0] - bonePos[0],
|
|
480
|
-
commit.pos[1] - bonePos[1],
|
|
481
|
-
commit.pos[2] - bonePos[2],
|
|
482
|
-
];
|
|
483
|
-
onPlacementChange(commit.assetId, {
|
|
484
|
-
bone,
|
|
485
|
-
position: relativePos,
|
|
486
|
-
rotation: [...commit.rot],
|
|
487
|
-
scale: commit.scl[0], // uniform scale
|
|
488
|
-
});
|
|
489
|
-
pendingCommit.current = null;
|
|
490
|
-
}, [onPlacementChange, placements]);
|
|
491
|
-
const snapToNearestBone = (0, react_1.useCallback)(() => {
|
|
492
|
-
if (!activeAssetId)
|
|
493
|
-
return;
|
|
494
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
495
|
-
if (slotIdx === undefined)
|
|
496
|
-
return;
|
|
497
|
-
const pos = positionRefs.current[slotIdx];
|
|
498
|
-
const nearest = (0, boneSnap_1.findNearestBone)([...pos]);
|
|
499
|
-
positionRefs.current[slotIdx] = nearest.position;
|
|
500
|
-
applySlotTransform(slotIdx, nearest.position, rotationRefs.current[slotIdx], scaleRefs.current[slotIdx]);
|
|
501
|
-
// Read fresh from prop placements (passed in as a stable-enough reference)
|
|
502
|
-
const currentPlacement = placements[activeAssetId];
|
|
503
|
-
onPlacementChange(activeAssetId, {
|
|
504
|
-
bone: nearest.bone,
|
|
505
|
-
position: [0, 0, 0], // Snapping directly to a bone means 0 relative offset
|
|
506
|
-
rotation: currentPlacement?.rotation ?? [0, 0, 0],
|
|
507
|
-
scale: currentPlacement?.scale ?? 1,
|
|
508
|
-
});
|
|
509
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
|
510
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
511
|
-
}, [activeAssetId, assetIdToSlot, onPlacementChange, placements]);
|
|
512
|
-
const resetToAttachBone = (0, react_1.useCallback)(() => {
|
|
513
|
-
if (!activeAssetId)
|
|
514
|
-
return;
|
|
515
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
516
|
-
if (slotIdx === undefined)
|
|
517
|
-
return;
|
|
518
|
-
const asset = slotMappings[slotIdx]?.asset;
|
|
519
|
-
const boneName = asset?.attach_bone ?? 'Head';
|
|
520
|
-
const bonePos = boneSnap_1.HUMANOID_BONES[boneName] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
521
|
-
positionRefs.current[slotIdx] = bonePos;
|
|
522
|
-
rotationRefs.current[slotIdx] = [0, 0, 0];
|
|
523
|
-
scaleRefs.current[slotIdx] = [1, 1, 1];
|
|
524
|
-
applySlotTransform(slotIdx, bonePos, [0, 0, 0], [1, 1, 1]);
|
|
525
|
-
onPlacementChange(activeAssetId, {
|
|
526
|
-
bone: boneName,
|
|
527
|
-
position: [0, 0, 0], // Relative to attach bone is 0
|
|
528
|
-
rotation: [0, 0, 0],
|
|
529
|
-
scale: 1,
|
|
530
|
-
});
|
|
531
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
|
532
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
533
|
-
}, [activeAssetId, assetIdToSlot, slotMappings, onPlacementChange]);
|
|
534
|
-
// -------------------------------------------------------------------------
|
|
535
|
-
// Entity picking — stable onPress callbacks per assetId
|
|
536
|
-
// -------------------------------------------------------------------------
|
|
537
|
-
const onPressCallbacks = (0, react_1.useRef)({});
|
|
538
|
-
// Rebuild only when selectAccessory reference changes (i.e. almost never)
|
|
539
|
-
(0, react_1.useEffect)(() => {
|
|
540
|
-
onPressCallbacks.current = {};
|
|
541
|
-
}, [selectAccessory]);
|
|
542
|
-
const getOnPress = (0, react_1.useCallback)((assetId) => {
|
|
543
|
-
if (!onPressCallbacks.current[assetId]) {
|
|
544
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
545
|
-
onPressCallbacks.current[assetId] = (_entity, _modelEntities) => {
|
|
546
|
-
selectAccessory(assetId);
|
|
547
|
-
isTouchingAccessory.current = true;
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
return onPressCallbacks.current[assetId];
|
|
551
|
-
}, [selectAccessory]);
|
|
552
|
-
// Sequential accessory mounting with GPU memory budget (50 MB).
|
|
553
|
-
// Loading multiple large GLBs simultaneously crashes the Adreno GPU driver
|
|
554
|
-
// (SIGSEGV in JNISurfaceTextu when buffers arrive while render thread is busy).
|
|
555
|
-
// The budget prevents loading so many that the GPU runs out of texture memory.
|
|
556
|
-
const [mountedSlots, setMountedSlots] = (0, react_1.useState)(0);
|
|
557
|
-
const totalLoadedBytes = (0, react_1.useRef)(0);
|
|
558
|
-
const [budgetExhausted, setBudgetExhausted] = (0, react_1.useState)(false);
|
|
559
|
-
(0, react_1.useEffect)(() => {
|
|
560
|
-
if (avatarReady && mountedSlots === 0 && slotMappings.length > 0) {
|
|
561
|
-
setMountedSlots(1);
|
|
562
|
-
}
|
|
563
|
-
}, [avatarReady, slotMappings.length, mountedSlots]);
|
|
564
|
-
const onAccessoryFileSize = (0, react_1.useCallback)((bytes) => {
|
|
565
|
-
totalLoadedBytes.current += bytes;
|
|
566
|
-
console.log(`[FilamentEditor] Accessory loaded: ${(bytes / 1024 / 1024).toFixed(1)}MB, ` +
|
|
567
|
-
`total: ${(totalLoadedBytes.current / 1024 / 1024).toFixed(1)}MB / ${(MAX_ACCESSORY_BYTES / 1024 / 1024).toFixed(0)}MB`);
|
|
568
|
-
if (totalLoadedBytes.current >= MAX_ACCESSORY_BYTES) {
|
|
569
|
-
console.warn('[FilamentEditor] GPU memory budget exhausted — skipping remaining accessories');
|
|
570
|
-
setBudgetExhausted(true);
|
|
571
|
-
}
|
|
572
|
-
}, []);
|
|
573
|
-
const onAccessoryLoaded = (0, react_1.useCallback)(() => {
|
|
574
|
-
if (totalLoadedBytes.current >= MAX_ACCESSORY_BYTES) {
|
|
575
|
-
console.warn('[FilamentEditor] GPU budget reached — not mounting more accessories');
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
setMountedSlots((prev) => Math.min(prev + 1, slotMappings.length));
|
|
579
|
-
}, [slotMappings.length]);
|
|
580
|
-
// --- Pan: drag accessory (if active) or orbit camera ---
|
|
581
|
-
// .runOnJS(true) ensures callbacks run on JS thread without Reanimated
|
|
582
|
-
// serialization, so refs are accessible and mutable.
|
|
583
|
-
const panGesture = react_native_gesture_handler_1.Gesture.Pan()
|
|
584
|
-
.maxPointers(1)
|
|
585
|
-
.runOnJS(true)
|
|
586
|
-
.onBegin((e) => {
|
|
587
|
-
if (activeAssetId) {
|
|
588
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
589
|
-
if (slotIdx !== undefined) {
|
|
590
|
-
gestureStartPos.current = [...positionRefs.current[slotIdx]];
|
|
591
|
-
isTouchingAccessory.current = true;
|
|
592
|
-
isGestureActive.current = true;
|
|
593
|
-
pendingCommit.current = {
|
|
594
|
-
assetId: activeAssetId,
|
|
595
|
-
slotIdx,
|
|
596
|
-
pos: [...positionRefs.current[slotIdx]],
|
|
597
|
-
rot: [...rotationRefs.current[slotIdx]],
|
|
598
|
-
scl: [...scaleRefs.current[slotIdx]],
|
|
599
|
-
};
|
|
600
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
else if (manipulator) {
|
|
604
|
-
manipulator.grabBegin(e.x, e.y, false);
|
|
605
|
-
}
|
|
606
|
-
})
|
|
607
|
-
.onUpdate((e) => {
|
|
608
|
-
if (activeAssetId && isTouchingAccessory.current) {
|
|
609
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
610
|
-
if (slotIdx !== undefined) {
|
|
611
|
-
const newPos = applyDragTranslate(gestureStartPos.current, e.translationX, e.translationY, sensitivity);
|
|
612
|
-
applySlotTransform(slotIdx, newPos, rotationRefs.current[slotIdx], scaleRefs.current[slotIdx]);
|
|
613
|
-
// Keep pendingCommit up to date with latest position
|
|
614
|
-
if (pendingCommit.current) {
|
|
615
|
-
pendingCommit.current.pos = newPos;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
else if (manipulator) {
|
|
620
|
-
manipulator.grabUpdate(e.x, e.y);
|
|
621
|
-
}
|
|
622
|
-
})
|
|
623
|
-
.onEnd(() => {
|
|
624
|
-
if (activeAssetId && isTouchingAccessory.current) {
|
|
625
|
-
commitActiveTransform();
|
|
626
|
-
}
|
|
627
|
-
if (manipulator) {
|
|
628
|
-
manipulator.grabEnd();
|
|
629
|
-
}
|
|
630
|
-
isTouchingAccessory.current = false;
|
|
631
|
-
isGestureActive.current = false;
|
|
632
|
-
})
|
|
633
|
-
.minDistance(5);
|
|
634
|
-
// --- Pinch: scale accessory (if active) or zoom camera ---
|
|
635
|
-
const pinchGesture = react_native_gesture_handler_1.Gesture.Pinch()
|
|
636
|
-
.runOnJS(true)
|
|
637
|
-
.onBegin(() => {
|
|
638
|
-
if (activeAssetId) {
|
|
639
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
640
|
-
if (slotIdx !== undefined) {
|
|
641
|
-
gestureStartScale.current = [...scaleRefs.current[slotIdx]];
|
|
642
|
-
hitScaleBound.current = false;
|
|
643
|
-
isGestureActive.current = true;
|
|
644
|
-
pendingCommit.current = {
|
|
645
|
-
assetId: activeAssetId,
|
|
646
|
-
slotIdx,
|
|
647
|
-
pos: [...positionRefs.current[slotIdx]],
|
|
648
|
-
rot: [...rotationRefs.current[slotIdx]],
|
|
649
|
-
scl: [...scaleRefs.current[slotIdx]],
|
|
650
|
-
};
|
|
651
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
})
|
|
655
|
-
.onUpdate((e) => {
|
|
656
|
-
if (activeAssetId) {
|
|
657
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
658
|
-
if (slotIdx !== undefined) {
|
|
659
|
-
const newScale = applyPinchScale(gestureStartScale.current, e.scale);
|
|
660
|
-
applySlotTransform(slotIdx, positionRefs.current[slotIdx], rotationRefs.current[slotIdx], newScale);
|
|
661
|
-
if (pendingCommit.current) {
|
|
662
|
-
pendingCommit.current.scl = newScale;
|
|
663
|
-
}
|
|
664
|
-
// Haptic feedback at scale boundaries (0.1 or 10)
|
|
665
|
-
const atBound = newScale[0] <= 0.1 || newScale[0] >= 10;
|
|
666
|
-
if (atBound && !hitScaleBound.current) {
|
|
667
|
-
hitScaleBound.current = true;
|
|
668
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
|
669
|
-
}
|
|
670
|
-
else if (!atBound) {
|
|
671
|
-
hitScaleBound.current = false;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
else if (manipulator) {
|
|
676
|
-
manipulator.scroll(e.focalX, e.focalY, (1 - e.scale) * 2);
|
|
677
|
-
}
|
|
678
|
-
})
|
|
679
|
-
.onEnd(() => {
|
|
680
|
-
isGestureActive.current = false;
|
|
681
|
-
if (activeAssetId) {
|
|
682
|
-
commitActiveTransform();
|
|
683
|
-
}
|
|
684
|
-
});
|
|
685
|
-
// --- Rotation: rotate accessory Y axis ---
|
|
686
|
-
const rotationGesture = react_native_gesture_handler_1.Gesture.Rotation()
|
|
687
|
-
.runOnJS(true)
|
|
688
|
-
.onBegin(() => {
|
|
689
|
-
if (activeAssetId) {
|
|
690
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
691
|
-
if (slotIdx !== undefined) {
|
|
692
|
-
gestureStartRot.current = [...rotationRefs.current[slotIdx]];
|
|
693
|
-
isGestureActive.current = true;
|
|
694
|
-
pendingCommit.current = {
|
|
695
|
-
assetId: activeAssetId,
|
|
696
|
-
slotIdx,
|
|
697
|
-
pos: [...positionRefs.current[slotIdx]],
|
|
698
|
-
rot: [...rotationRefs.current[slotIdx]],
|
|
699
|
-
scl: [...scaleRefs.current[slotIdx]],
|
|
700
|
-
};
|
|
701
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
})
|
|
705
|
-
.onUpdate((e) => {
|
|
706
|
-
if (activeAssetId) {
|
|
707
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
708
|
-
if (slotIdx !== undefined) {
|
|
709
|
-
const newRot = applyRotation(gestureStartRot.current, e.rotation, 0);
|
|
710
|
-
applySlotTransform(slotIdx, positionRefs.current[slotIdx], newRot, scaleRefs.current[slotIdx]);
|
|
711
|
-
if (pendingCommit.current) {
|
|
712
|
-
pendingCommit.current.rot = newRot;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
})
|
|
717
|
-
.onEnd(() => {
|
|
718
|
-
isGestureActive.current = false;
|
|
719
|
-
if (activeAssetId) {
|
|
720
|
-
commitActiveTransform();
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
// --- Long press: bone snap ---
|
|
724
|
-
const longPressGesture = react_native_gesture_handler_1.Gesture.LongPress()
|
|
725
|
-
.runOnJS(true)
|
|
726
|
-
.minDuration(500)
|
|
727
|
-
.onEnd((_e, success) => {
|
|
728
|
-
if (success && activeAssetId) {
|
|
729
|
-
snapToNearestBone();
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
// Compose gestures:
|
|
733
|
-
// We use Simultaneous for everything because placing Pan in a Race with Pinch
|
|
734
|
-
// means the first finger touching the screen locks out Pinch completely!
|
|
735
|
-
const pinchRotate = react_native_gesture_handler_1.Gesture.Simultaneous(pinchGesture, rotationGesture);
|
|
736
|
-
const mainGesture = react_native_gesture_handler_1.Gesture.Simultaneous(longPressGesture, panGesture, pinchRotate);
|
|
737
|
-
// -------------------------------------------------------------------------
|
|
738
|
-
// Overlay animated styles
|
|
739
|
-
// -------------------------------------------------------------------------
|
|
740
|
-
const overlayOpacity = (0, react_native_reanimated_1.useSharedValue)(activeAssetId ? 1 : 0);
|
|
741
|
-
(0, react_1.useEffect)(() => {
|
|
742
|
-
overlayOpacity.value = (0, react_native_reanimated_1.withTiming)(activeAssetId ? 1 : 0, { duration: 200 });
|
|
743
|
-
}, [activeAssetId, overlayOpacity]);
|
|
744
|
-
const overlayAnimatedStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => ({
|
|
745
|
-
opacity: overlayOpacity.value,
|
|
746
|
-
pointerEvents: overlayOpacity.value > 0.5 ? 'auto' : 'none',
|
|
747
|
-
}));
|
|
748
|
-
// Selection pulse applied to the asset name badge
|
|
749
|
-
const selectionBadgeStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => ({
|
|
750
|
-
transform: [{ scale: selectionScale.value }],
|
|
751
|
-
}));
|
|
752
|
-
// Find active asset name for overlay
|
|
753
|
-
const activeAssetName = (0, react_1.useMemo)(() => {
|
|
754
|
-
if (!activeAssetId)
|
|
755
|
-
return '';
|
|
756
|
-
const mapping = slotMappings.find((m) => m.assetId === activeAssetId);
|
|
757
|
-
return mapping?.asset.name ?? 'Accessory';
|
|
758
|
-
}, [activeAssetId, slotMappings]);
|
|
759
|
-
// -------------------------------------------------------------------------
|
|
760
|
-
// Render
|
|
761
|
-
// -------------------------------------------------------------------------
|
|
762
|
-
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, style], onLayout: onLayout, children: [(0, jsx_runtime_1.jsx)(react_native_gesture_handler_1.GestureDetector, { gesture: mainGesture, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sceneContainer, children: (0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { style: styles.filamentView, children: [(0, jsx_runtime_1.jsx)(react_native_filament_1.DefaultLight, {}), manipulator && react_native_filament_1.Camera({ cameraManipulator: manipulator }), localAvatarUri && ((0, jsx_runtime_1.jsx)(AvatarModel, { uri: localAvatarUri, skinColor: skinColor, onLoaded: () => {
|
|
763
|
-
console.log('[FilamentEditor] Avatar model loaded, accessories may now mount');
|
|
764
|
-
setAvatarReady(true);
|
|
765
|
-
} })), slotMappings.slice(0, mountedSlots).map((mapping, slotIdx) => {
|
|
766
|
-
if (slotIdx >= MAX_SLOTS) {
|
|
767
|
-
console.warn('[FilamentEditor] Too many accessories, skipping:', mapping.assetId);
|
|
768
|
-
return null;
|
|
769
|
-
}
|
|
770
|
-
return ((0, jsx_runtime_1.jsx)(AuthedModel, { url: (0, studioApi_1.assetFileUrl)(mapping.asset), initialTranslate: positionRefs.current[slotIdx], initialRotate: rotationRefs.current[slotIdx], initialScale: scaleRefs.current[slotIdx], onFileSize: onAccessoryFileSize, onTransformReady: (fn) => {
|
|
771
|
-
slotTransformApply.current[slotIdx] = fn;
|
|
772
|
-
}, onPress: getOnPress(mapping.assetId), onLoaded: slotIdx === mountedSlots - 1 && !budgetExhausted ? onAccessoryLoaded : undefined, ...(newAccessorySlots.current.has(slotIdx) && {
|
|
773
|
-
autoNormalizeMaxSize: AUTO_NORMALIZE_MAX_SIZE,
|
|
774
|
-
boneWorldPos: boneSnap_1.HUMANOID_BONES[mapping.asset.attach_bone ?? 'Head'] ?? boneSnap_1.HUMANOID_BONES['Head'],
|
|
775
|
-
onAutoNormalized: (relOffset, scale) => {
|
|
776
|
-
const bone = boneSnap_1.HUMANOID_BONES[mapping.asset.attach_bone ?? 'Head'] ?? boneSnap_1.HUMANOID_BONES['Head'];
|
|
777
|
-
positionRefs.current[slotIdx] = [
|
|
778
|
-
bone[0] + relOffset[0],
|
|
779
|
-
bone[1] + relOffset[1],
|
|
780
|
-
bone[2] + relOffset[2],
|
|
781
|
-
];
|
|
782
|
-
scaleRefs.current[slotIdx] = [scale, scale, scale];
|
|
783
|
-
newAccessorySlots.current.delete(slotIdx);
|
|
784
|
-
onPlacementChange(mapping.assetId, {
|
|
785
|
-
bone: mapping.asset.attach_bone ?? 'Head',
|
|
786
|
-
position: [relOffset[0], relOffset[1], relOffset[2]],
|
|
787
|
-
rotation: [0, 0, 0],
|
|
788
|
-
scale,
|
|
789
|
-
});
|
|
790
|
-
},
|
|
791
|
-
}) }, mapping.assetId));
|
|
792
|
-
})] }) }) }), (0, jsx_runtime_1.jsxs)(react_native_reanimated_1.default.View, { style: [styles.overlay, overlayAnimatedStyle], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.overlayTop, children: [(0, jsx_runtime_1.jsx)(react_native_reanimated_1.default.View, { style: [styles.assetNameBadge, selectionBadgeStyle], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.assetNameText, numberOfLines: 1, children: activeAssetName }) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.overlayActions, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayButton, onPress: resetToAttachBone, children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "locate-outline", size: 20, color: COLORS.textPrimary }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayButton, onPress: () => {
|
|
793
|
-
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
794
|
-
setShowPrecision((prev) => !prev);
|
|
795
|
-
}, children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "settings-outline", size: 20, color: showPrecision ? COLORS.accent : COLORS.textPrimary }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [styles.overlayButton, styles.doneButton], onPress: () => {
|
|
796
|
-
// Ensure pendingCommit is populated from current refs
|
|
797
|
-
// (may not have been set if user only used PrecisionPanel, not gestures)
|
|
798
|
-
if (activeAssetId) {
|
|
799
|
-
const slotIdx = assetIdToSlot[activeAssetId];
|
|
800
|
-
if (slotIdx !== undefined) {
|
|
801
|
-
pendingCommit.current = {
|
|
802
|
-
assetId: activeAssetId,
|
|
803
|
-
slotIdx,
|
|
804
|
-
pos: positionRefs.current[slotIdx],
|
|
805
|
-
rot: rotationRefs.current[slotIdx],
|
|
806
|
-
scl: scaleRefs.current[slotIdx],
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
commitActiveTransform();
|
|
811
|
-
deselectAccessory();
|
|
812
|
-
onDone();
|
|
813
|
-
}, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.doneText, children: "Done" }) })] })] }), activeAssetId ? ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayTapZone, onPress: deselectAccessory })) : ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.overlayTapZone, pointerEvents: "none" }))] }), showPrecision && activeAssetId && (0, jsx_runtime_1.jsx)(PrecisionPanel_1.default, {})] }));
|
|
814
|
-
}
|
|
815
|
-
exports.default = FilamentEditor;
|
|
816
|
-
// ---------------------------------------------------------------------------
|
|
817
|
-
// Styles
|
|
818
|
-
// ---------------------------------------------------------------------------
|
|
819
|
-
const styles = react_native_1.StyleSheet.create({
|
|
820
|
-
container: {
|
|
821
|
-
flex: 1,
|
|
822
|
-
},
|
|
823
|
-
sceneContainer: {
|
|
824
|
-
flex: 1,
|
|
825
|
-
},
|
|
826
|
-
filamentView: {
|
|
827
|
-
flex: 1,
|
|
828
|
-
backgroundColor: COLORS.bg,
|
|
829
|
-
},
|
|
830
|
-
overlay: {
|
|
831
|
-
...react_native_1.StyleSheet.absoluteFillObject,
|
|
832
|
-
justifyContent: 'space-between',
|
|
833
|
-
},
|
|
834
|
-
overlayTop: {
|
|
835
|
-
flexDirection: 'row',
|
|
836
|
-
justifyContent: 'space-between',
|
|
837
|
-
alignItems: 'center',
|
|
838
|
-
paddingHorizontal: 16,
|
|
839
|
-
paddingTop: 12,
|
|
840
|
-
},
|
|
841
|
-
assetNameBadge: {
|
|
842
|
-
backgroundColor: COLORS.card,
|
|
843
|
-
borderRadius: 8,
|
|
844
|
-
paddingHorizontal: 12,
|
|
845
|
-
paddingVertical: 6,
|
|
846
|
-
borderWidth: 1,
|
|
847
|
-
borderColor: COLORS.border,
|
|
848
|
-
maxWidth: 160,
|
|
849
|
-
},
|
|
850
|
-
assetNameText: {
|
|
851
|
-
color: COLORS.textPrimary,
|
|
852
|
-
fontSize: 13,
|
|
853
|
-
fontWeight: '600',
|
|
854
|
-
},
|
|
855
|
-
overlayActions: {
|
|
856
|
-
flexDirection: 'row',
|
|
857
|
-
alignItems: 'center',
|
|
858
|
-
gap: 8,
|
|
859
|
-
},
|
|
860
|
-
overlayButton: {
|
|
861
|
-
backgroundColor: COLORS.card,
|
|
862
|
-
borderRadius: 8,
|
|
863
|
-
padding: 8,
|
|
864
|
-
borderWidth: 1,
|
|
865
|
-
borderColor: COLORS.border,
|
|
866
|
-
},
|
|
867
|
-
doneButton: {
|
|
868
|
-
backgroundColor: COLORS.accent,
|
|
869
|
-
borderColor: COLORS.accent,
|
|
870
|
-
paddingHorizontal: 16,
|
|
871
|
-
},
|
|
872
|
-
doneText: {
|
|
873
|
-
color: '#fff',
|
|
874
|
-
fontSize: 14,
|
|
875
|
-
fontWeight: '700',
|
|
876
|
-
},
|
|
877
|
-
overlayTapZone: {
|
|
878
|
-
flex: 1,
|
|
879
|
-
},
|
|
880
|
-
});
|