talking-head-studio 0.4.11 → 0.4.12
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 +279 -193
- package/dist/TalkingHead.d.ts +28 -3
- package/dist/TalkingHead.js +21 -2
- package/dist/TalkingHead.web.d.ts +31 -4
- package/dist/TalkingHead.web.js +11 -1
- package/dist/TalkingHeadVisualization.d.ts +22 -0
- package/dist/TalkingHeadVisualization.js +30 -10
- package/dist/api/studioApi.d.ts +12 -1
- package/dist/api/studioApi.js +16 -2
- package/dist/contract.d.ts +14 -0
- package/dist/contract.js +30 -0
- package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
- package/dist/core/avatar/avatarCapabilities.js +100 -0
- package/dist/core/avatar/backends/gaussian.js +6 -4
- package/dist/core/avatar/motion.d.ts +1713 -0
- package/dist/core/avatar/motion.js +550 -0
- package/dist/core/avatar/motionRuntime.d.ts +46 -0
- package/dist/core/avatar/motionRuntime.js +84 -0
- package/dist/core/avatar/schema.d.ts +33 -5
- package/dist/core/avatar/visemes.d.ts +16 -1
- package/dist/core/avatar/visemes.js +48 -1
- package/dist/editor/AvatarCanvas.js +92 -1
- package/dist/editor/AvatarEditor.native.js +1 -0
- package/dist/editor/AvatarModel.js +1 -0
- package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.js +176 -112
- package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.web.js +30 -28
- package/dist/editor/RigidAccessory.js +17 -2
- package/dist/editor/SkinnedClothing.js +1 -0
- package/dist/editor/boneLockedDrag.d.ts +11 -0
- package/dist/editor/boneLockedDrag.js +68 -0
- package/dist/editor/boneSnap.web.d.ts +27 -0
- package/dist/editor/boneSnap.web.js +99 -0
- package/dist/editor/index.web.d.ts +10 -0
- package/dist/editor/index.web.js +26 -0
- package/dist/editor/sounds/haha.wav +0 -0
- package/dist/editor/sounds/owie.wav +0 -0
- package/dist/editor/sounds/stop.wav +0 -0
- package/dist/editor/studioTheme.d.ts +14 -14
- package/dist/editor/studioTheme.js +17 -14
- package/dist/editor/types.d.ts +1 -0
- package/dist/html/accessories.d.ts +7 -0
- package/dist/html/accessories.js +149 -0
- package/dist/html/motion.d.ts +1 -0
- package/dist/html/motion.js +189 -0
- package/dist/html/visemes.d.ts +7 -0
- package/dist/html/visemes.js +348 -0
- package/dist/html.d.ts +1 -1
- package/dist/html.js +55 -732
- package/dist/index.d.ts +7 -3
- package/dist/index.js +17 -1
- package/dist/index.web.d.ts +18 -1
- package/dist/index.web.js +36 -3
- package/dist/sketchfab/api.js +1 -0
- package/dist/sketchfab/glbInspect.d.ts +22 -0
- package/dist/sketchfab/glbInspect.js +58 -0
- package/dist/sketchfab/index.d.ts +3 -0
- package/dist/sketchfab/index.js +8 -1
- package/dist/sketchfab/inspectRemote.d.ts +13 -0
- package/dist/sketchfab/inspectRemote.js +77 -0
- package/dist/sketchfab/types.d.ts +10 -0
- package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
- package/dist/studio/AccessoryBrowserScreen.js +626 -0
- package/dist/studio/AccessoryPanel.d.ts +10 -0
- package/dist/studio/AccessoryPanel.js +396 -0
- package/dist/studio/AppearancePanel.d.ts +9 -0
- package/dist/studio/AppearancePanel.js +77 -0
- package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
- package/dist/studio/AvatarCreatorScreen.js +806 -0
- package/dist/studio/AvatarEditorScreen.d.ts +14 -0
- package/dist/studio/AvatarEditorScreen.js +510 -0
- package/dist/studio/AvatarGrid.d.ts +23 -0
- package/dist/studio/AvatarGrid.js +257 -0
- package/dist/studio/ColorSwatch.d.ts +8 -0
- package/dist/studio/ColorSwatch.js +100 -0
- package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
- package/dist/studio/CreateVoiceProfileSheet.js +242 -0
- package/dist/studio/DetailsPanel.d.ts +15 -0
- package/dist/studio/DetailsPanel.js +239 -0
- package/dist/studio/FilamentEditor.d.ts +2 -0
- package/dist/studio/FilamentEditor.js +6 -0
- package/dist/studio/PrecisionPanel.d.ts +2 -0
- package/dist/studio/PrecisionPanel.js +7 -0
- package/dist/studio/PublicGalleryScreen.d.ts +5 -0
- package/dist/studio/PublicGalleryScreen.js +358 -0
- package/dist/studio/SketchfabModelCard.d.ts +20 -0
- package/dist/studio/SketchfabModelCard.js +104 -0
- package/dist/studio/StudioBrowseHeader.d.ts +9 -0
- package/dist/studio/StudioBrowseHeader.js +28 -0
- package/dist/studio/StudioEmptyState.d.ts +8 -0
- package/dist/studio/StudioEmptyState.js +29 -0
- package/dist/studio/StudioFloatingAction.d.ts +13 -0
- package/dist/studio/StudioFloatingAction.js +42 -0
- package/dist/studio/StudioSectionHeader.d.ts +7 -0
- package/dist/studio/StudioSectionHeader.js +27 -0
- package/dist/studio/StudioSurfaceCard.d.ts +8 -0
- package/dist/studio/StudioSurfaceCard.js +20 -0
- package/dist/studio/VoicePanel.d.ts +15 -0
- package/dist/studio/VoicePanel.js +305 -0
- package/dist/studio/constants.d.ts +3 -0
- package/dist/studio/constants.js +6 -0
- package/dist/studio/index.d.ts +29 -0
- package/dist/studio/index.js +54 -0
- package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
- package/dist/studio/useSketchfabCapabilities.js +82 -0
- package/dist/tts/useDirectVisemeStream.js +15 -10
- package/dist/utils/avatarUtils.js +92 -5
- package/dist/utils/faceLandmarkerToShapeWeights.js +2 -4
- package/dist/voice/useAudioPlayer.js +17 -4
- package/dist/voice/useVoicePreview.js +4 -2
- package/dist/wardrobe/index.d.ts +1 -0
- package/dist/wardrobe/index.js +6 -1
- package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
- package/dist/wardrobe/useAccessoryGestures.js +94 -0
- package/dist/wardrobe/useAvatarWardrobeHydration.js +8 -2
- package/dist/wardrobe/useStudioAvatar.js +11 -2
- package/dist/wardrobe/wardrobeStore.d.ts +2 -0
- package/dist/wardrobe/wardrobeStore.js +12 -2
- package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
- package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
- package/dist/wgpu/WgpuAvatar.d.ts +26 -2
- package/dist/wgpu/WgpuAvatar.js +296 -39
- package/dist/wgpu/accessoryDefaults.d.ts +12 -0
- package/dist/wgpu/accessoryDefaults.js +19 -0
- package/dist/wgpu/blobShim.d.ts +2 -0
- package/dist/wgpu/blobShim.js +191 -0
- package/dist/wgpu/index.d.ts +1 -0
- package/dist/wgpu/index.js +4 -1
- package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
- package/dist/wgpu/loadGLTFFromUri.js +75 -0
- package/dist/wgpu/morphTables.js +21 -10
- package/dist/wgpu/motionState.d.ts +20 -0
- package/dist/wgpu/motionState.js +31 -0
- package/dist/wgpu/patchThreeForRN.d.ts +28 -0
- package/dist/wgpu/patchThreeForRN.js +292 -0
- package/dist/wgpu/scenePlacement.d.ts +5 -0
- package/dist/wgpu/scenePlacement.js +50 -0
- package/dist/wgpu/useAuthedModelUri.js +4 -2
- package/dist/wgpu/useNativeGLTF.d.ts +7 -0
- package/dist/wgpu/useNativeGLTF.js +36 -0
- package/package.json +97 -31
|
@@ -48,18 +48,21 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
48
48
|
* Drop-in replacement: same FaceSqueezeEditorProps interface.
|
|
49
49
|
* The .web.tsx stub is shared — import from FaceSqueezeEditor.web.tsx.
|
|
50
50
|
*/
|
|
51
|
+
require("../wgpu/blobShim");
|
|
51
52
|
const react_1 = require("react");
|
|
52
53
|
const react_native_1 = require("react-native");
|
|
53
|
-
const
|
|
54
|
-
const native_2 = require("@react-three/drei/native");
|
|
54
|
+
const fiber_1 = require("@react-three/fiber");
|
|
55
55
|
const THREE = __importStar(require("three"));
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const expo_asset_1 = require("expo-asset");
|
|
56
|
+
const avatarUtils_1 = require("../utils/avatarUtils");
|
|
57
|
+
const R3FWebGpuCanvas_1 = require("../wgpu/R3FWebGpuCanvas");
|
|
58
|
+
const patchThreeForRN_1 = require("../wgpu/patchThreeForRN");
|
|
59
|
+
const useNativeGLTF_1 = require("../wgpu/useNativeGLTF");
|
|
61
60
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
61
|
exports.FACE_SQUEEZE_LOCAL_MODULE = require('../assets/face-squeeze-local.glb');
|
|
62
|
+
(0, patchThreeForRN_1.installThreeRNPatches)(false);
|
|
63
|
+
function emitHaptic() {
|
|
64
|
+
// Optional in the example path.
|
|
65
|
+
}
|
|
63
66
|
// ---------------------------------------------------------------------------
|
|
64
67
|
// Constants (identical to Filament version)
|
|
65
68
|
// ---------------------------------------------------------------------------
|
|
@@ -67,17 +70,6 @@ const SPRING_DURATION_MS = 300;
|
|
|
67
70
|
const EMOTION_HOLD_MS = 900;
|
|
68
71
|
const IDLE_EMOTION_INTERVAL_MS = 30000;
|
|
69
72
|
const INITIAL_IDLE_EMOTION_DELAY_MS = 1200;
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
71
|
-
const SOUND_OWIE = require('./sounds/owie.wav');
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
-
const SOUND_STOP = require('./sounds/stop.wav');
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
75
|
-
const SOUND_HAHA = require('./sounds/haha.wav');
|
|
76
|
-
const REACTION_SOUNDS = [
|
|
77
|
-
SOUND_OWIE,
|
|
78
|
-
SOUND_STOP,
|
|
79
|
-
SOUND_HAHA,
|
|
80
|
-
];
|
|
81
73
|
const EMOTION_PRESETS = {
|
|
82
74
|
owie: {
|
|
83
75
|
eyeBlinkLeft: 1.0, eyeBlinkRight: 1.0,
|
|
@@ -158,12 +150,56 @@ function pickIdleEmotion() {
|
|
|
158
150
|
}
|
|
159
151
|
return IDLE_EMOTIONS[0].key;
|
|
160
152
|
}
|
|
153
|
+
function morphAliases(name) {
|
|
154
|
+
const out = new Set([name, name.toLowerCase()]);
|
|
155
|
+
const pairs = [
|
|
156
|
+
['Left', '_L'],
|
|
157
|
+
['Right', '_R'],
|
|
158
|
+
['Left', 'Left'],
|
|
159
|
+
['Right', 'Right'],
|
|
160
|
+
['left', '_l'],
|
|
161
|
+
['right', '_r'],
|
|
162
|
+
];
|
|
163
|
+
for (const [needle, replacement] of pairs) {
|
|
164
|
+
if (!name.includes(needle))
|
|
165
|
+
continue;
|
|
166
|
+
out.add(name.replace(needle, replacement));
|
|
167
|
+
out.add(name.replace(needle, replacement).toLowerCase());
|
|
168
|
+
out.add(name.replace(needle, replacement.replace('_', '')));
|
|
169
|
+
out.add(name.replace(needle, replacement.replace('_', '')).toLowerCase());
|
|
170
|
+
}
|
|
171
|
+
if (name.endsWith('Left')) {
|
|
172
|
+
const stem = name.slice(0, -4);
|
|
173
|
+
out.add(`${stem}_L`);
|
|
174
|
+
out.add(`${stem}_l`);
|
|
175
|
+
out.add(`${stem}L`);
|
|
176
|
+
out.add(`${stem}Left`);
|
|
177
|
+
}
|
|
178
|
+
if (name.endsWith('Right')) {
|
|
179
|
+
const stem = name.slice(0, -5);
|
|
180
|
+
out.add(`${stem}_R`);
|
|
181
|
+
out.add(`${stem}_r`);
|
|
182
|
+
out.add(`${stem}R`);
|
|
183
|
+
out.add(`${stem}Right`);
|
|
184
|
+
}
|
|
185
|
+
return [...out];
|
|
186
|
+
}
|
|
187
|
+
function resolveMorphIndices(index, name) {
|
|
188
|
+
const found = new Set();
|
|
189
|
+
for (const alias of morphAliases(name)) {
|
|
190
|
+
const i = index.get(alias) ?? index.get(alias.toLowerCase());
|
|
191
|
+
if (i !== undefined)
|
|
192
|
+
found.add(i);
|
|
193
|
+
}
|
|
194
|
+
return [...found];
|
|
195
|
+
}
|
|
161
196
|
// ---------------------------------------------------------------------------
|
|
162
197
|
// Inner R3F scene — runs inside Canvas
|
|
163
198
|
// ---------------------------------------------------------------------------
|
|
164
199
|
function AvatarScene({ uri, morphState, onReady, }) {
|
|
165
|
-
const { camera } = (0,
|
|
166
|
-
const gltf = (0,
|
|
200
|
+
const { camera } = (0, fiber_1.useThree)();
|
|
201
|
+
const { gltf } = (0, useNativeGLTF_1.useNativeGLTF)(uri);
|
|
202
|
+
const scene = gltf?.scene ?? null;
|
|
167
203
|
const readyFiredRef = (0, react_1.useRef)(false);
|
|
168
204
|
// Position camera to match Filament version
|
|
169
205
|
(0, react_1.useLayoutEffect)(() => {
|
|
@@ -176,11 +212,11 @@ function AvatarScene({ uri, morphState, onReady, }) {
|
|
|
176
212
|
}, [camera]);
|
|
177
213
|
// Find best mesh and build morph index
|
|
178
214
|
(0, react_1.useEffect)(() => {
|
|
179
|
-
if (!
|
|
215
|
+
if (!scene)
|
|
180
216
|
return;
|
|
181
217
|
let best = null;
|
|
182
218
|
let bestCount = 0;
|
|
183
|
-
|
|
219
|
+
scene.traverse((node) => {
|
|
184
220
|
if (!(node instanceof THREE.Mesh))
|
|
185
221
|
return;
|
|
186
222
|
const count = Object.keys(node.morphTargetDictionary ?? {}).length;
|
|
@@ -203,33 +239,31 @@ function AvatarScene({ uri, morphState, onReady, }) {
|
|
|
203
239
|
readyFiredRef.current = true;
|
|
204
240
|
onReady();
|
|
205
241
|
}
|
|
206
|
-
}, [
|
|
242
|
+
}, [scene, morphState, onReady]);
|
|
207
243
|
// Apply morphState.weights to mesh every frame
|
|
208
|
-
(0,
|
|
244
|
+
(0, fiber_1.useFrame)(() => {
|
|
209
245
|
const { mesh, weights, index } = morphState.current;
|
|
210
246
|
if (!mesh?.morphTargetInfluences)
|
|
211
247
|
return;
|
|
212
248
|
for (const [name, w] of Object.entries(weights)) {
|
|
213
|
-
const i
|
|
214
|
-
if (i !== undefined)
|
|
249
|
+
for (const i of resolveMorphIndices(index, name)) {
|
|
215
250
|
mesh.morphTargetInfluences[i] = w;
|
|
251
|
+
}
|
|
216
252
|
}
|
|
217
253
|
});
|
|
218
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.65 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [1, 3, 2], intensity: 1.3 }), (0, jsx_runtime_1.jsx)("primitive", { object:
|
|
254
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("ambientLight", { intensity: 0.65 }), (0, jsx_runtime_1.jsx)("directionalLight", { position: [1, 3, 2], intensity: 1.3 }), scene ? (0, jsx_runtime_1.jsx)("primitive", { object: scene }) : null] }));
|
|
219
255
|
}
|
|
220
256
|
// ---------------------------------------------------------------------------
|
|
221
257
|
// Main component
|
|
222
258
|
// ---------------------------------------------------------------------------
|
|
223
|
-
function FaceSqueezeEditor({ onClose }) {
|
|
259
|
+
function FaceSqueezeEditor({ onClose, avatarModule = exports.FACE_SQUEEZE_LOCAL_MODULE, }) {
|
|
224
260
|
const [localUri, setLocalUri] = (0, react_1.useState)(null);
|
|
225
261
|
const [isReady, setIsReady] = (0, react_1.useState)(false);
|
|
226
262
|
(0, react_1.useEffect)(() => {
|
|
227
263
|
let cancelled = false;
|
|
228
|
-
(async () => {
|
|
264
|
+
void (async () => {
|
|
229
265
|
try {
|
|
230
|
-
const
|
|
231
|
-
await asset.downloadAsync();
|
|
232
|
-
const uri = asset.localUri ?? asset.uri;
|
|
266
|
+
const uri = await (0, avatarUtils_1.resolveLocalAssetUrl)(avatarModule);
|
|
233
267
|
if (!cancelled && uri)
|
|
234
268
|
setLocalUri(uri);
|
|
235
269
|
}
|
|
@@ -238,16 +272,13 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
238
272
|
}
|
|
239
273
|
})();
|
|
240
274
|
return () => { cancelled = true; };
|
|
241
|
-
}, []);
|
|
275
|
+
}, [avatarModule]);
|
|
242
276
|
// Shared morph state — mutated directly, never triggers re-render
|
|
243
277
|
const morphState = (0, react_1.useRef)({
|
|
244
278
|
weights: {},
|
|
245
279
|
index: new Map(),
|
|
246
280
|
mesh: null,
|
|
247
281
|
});
|
|
248
|
-
// ---- Audio ----
|
|
249
|
-
const reactionSoundIdx = (0, react_1.useRef)(0);
|
|
250
|
-
const player = (0, expo_audio_1.useAudioPlayer)(REACTION_SOUNDS[0]);
|
|
251
282
|
// ---- Timers ----
|
|
252
283
|
const springRef = (0, react_1.useRef)(null);
|
|
253
284
|
const emotionHoldTimerRef = (0, react_1.useRef)(null);
|
|
@@ -255,19 +286,17 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
255
286
|
const lipSyncIntervalRef = (0, react_1.useRef)(null);
|
|
256
287
|
// ---- View geometry (for gesture zone math) ----
|
|
257
288
|
const viewSize = (0, react_1.useRef)({ width: 1, height: 1 });
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
const
|
|
289
|
+
const touchStartRef = (0, react_1.useRef)({ x: 0, y: 0 });
|
|
290
|
+
const touchZoneRef = (0, react_1.useRef)('cheek_left');
|
|
291
|
+
const lastPanUpdateTsRef = (0, react_1.useRef)(0);
|
|
292
|
+
const pinchStartDistanceRef = (0, react_1.useRef)(null);
|
|
293
|
+
const activeTouchesRef = (0, react_1.useRef)(0);
|
|
294
|
+
const hasMovedRef = (0, react_1.useRef)(false);
|
|
264
295
|
const onLayout = (0, react_1.useCallback)((e) => {
|
|
265
296
|
const w = Math.max(1, e.nativeEvent.layout.width);
|
|
266
297
|
const h = Math.max(1, e.nativeEvent.layout.height);
|
|
267
298
|
viewSize.current = { width: w, height: h };
|
|
268
|
-
|
|
269
|
-
viewHeightSv.value = h;
|
|
270
|
-
}, [viewHeightSv, viewWidthSv]);
|
|
299
|
+
}, []);
|
|
271
300
|
// ---- Weight helpers ----
|
|
272
301
|
const applyWeights = (0, react_1.useCallback)((targets) => {
|
|
273
302
|
for (const [name, w] of Object.entries(targets)) {
|
|
@@ -351,15 +380,8 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
351
380
|
}, TICK_MS);
|
|
352
381
|
}, [applyWeights, stopLipSync]);
|
|
353
382
|
const playReaction = (0, react_1.useCallback)((durationMs = 1400) => {
|
|
354
|
-
const idx = reactionSoundIdx.current % REACTION_SOUNDS.length;
|
|
355
|
-
reactionSoundIdx.current += 1;
|
|
356
|
-
try {
|
|
357
|
-
player.replace(REACTION_SOUNDS[idx]);
|
|
358
|
-
player.play();
|
|
359
|
-
}
|
|
360
|
-
catch { /* ignore */ }
|
|
361
383
|
startLipSync(durationMs);
|
|
362
|
-
}, [
|
|
384
|
+
}, [startLipSync]);
|
|
363
385
|
// Cleanup
|
|
364
386
|
(0, react_1.useEffect)(() => () => {
|
|
365
387
|
if (springRef.current !== null)
|
|
@@ -384,10 +406,9 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
384
406
|
};
|
|
385
407
|
}, [isReady, playEmotionBurst]);
|
|
386
408
|
// ---- Gesture callbacks (JS side — called via runOnJS) ----
|
|
387
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
388
409
|
const onPanBeginJs = (0, react_1.useCallback)((_x, _y, _zone) => {
|
|
389
410
|
stopSpring();
|
|
390
|
-
|
|
411
|
+
emitHaptic();
|
|
391
412
|
}, [stopSpring]);
|
|
392
413
|
const onPanUpdateJs = (0, react_1.useCallback)((zone, dx, dy, isLeftHalf) => {
|
|
393
414
|
const u = {};
|
|
@@ -463,7 +484,7 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
463
484
|
}, [springToZero]);
|
|
464
485
|
const onPinchBeginJs = (0, react_1.useCallback)(() => {
|
|
465
486
|
stopSpring();
|
|
466
|
-
|
|
487
|
+
emitHaptic();
|
|
467
488
|
}, [stopSpring]);
|
|
468
489
|
const onPinchUpdateJs = (0, react_1.useCallback)((scale, delta) => {
|
|
469
490
|
if (scale < 1) {
|
|
@@ -482,82 +503,125 @@ function FaceSqueezeEditor({ onClose }) {
|
|
|
482
503
|
const relY = y / Math.max(1, viewSize.current.height);
|
|
483
504
|
if (relY < 0.16 || relY > 0.52)
|
|
484
505
|
return;
|
|
485
|
-
|
|
506
|
+
emitHaptic();
|
|
486
507
|
playReaction();
|
|
487
508
|
playEmotionBurst('owie');
|
|
488
509
|
}, [playEmotionBurst, playReaction]);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
.minDistance(0)
|
|
492
|
-
.onBegin((e) => {
|
|
493
|
-
'worklet';
|
|
494
|
-
touchStartXSv.value = e.x;
|
|
495
|
-
touchStartYSv.value = e.y;
|
|
496
|
-
const relY = e.y / Math.max(1, viewHeightSv.value);
|
|
497
|
-
let zone;
|
|
510
|
+
const getZoneForPoint = (0, react_1.useCallback)((x, y) => {
|
|
511
|
+
const relY = y / Math.max(1, viewSize.current.height);
|
|
498
512
|
if (relY < 0.20)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
513
|
+
return 'brow';
|
|
514
|
+
if (relY < 0.38)
|
|
515
|
+
return 'eye';
|
|
516
|
+
if (relY > 0.72)
|
|
517
|
+
return 'jaw';
|
|
518
|
+
return x < viewSize.current.width / 2 ? 'cheek_left' : 'cheek_right';
|
|
519
|
+
}, []);
|
|
520
|
+
const distanceBetweenTouches = (0, react_1.useCallback)((event) => {
|
|
521
|
+
const touches = event.nativeEvent.touches;
|
|
522
|
+
if (touches.length < 2)
|
|
523
|
+
return null;
|
|
524
|
+
const [a, b] = [touches[0], touches[1]];
|
|
525
|
+
const dx = b.pageX - a.pageX;
|
|
526
|
+
const dy = b.pageY - a.pageY;
|
|
527
|
+
return Math.hypot(dx, dy);
|
|
528
|
+
}, []);
|
|
529
|
+
const handleResponderGrant = (0, react_1.useCallback)((event) => {
|
|
530
|
+
activeTouchesRef.current = event.nativeEvent.touches.length;
|
|
531
|
+
hasMovedRef.current = false;
|
|
532
|
+
if (event.nativeEvent.touches.length >= 2) {
|
|
533
|
+
pinchStartDistanceRef.current = distanceBetweenTouches(event);
|
|
534
|
+
onPinchBeginJs();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const { locationX: x, locationY: y } = event.nativeEvent;
|
|
538
|
+
touchStartRef.current = { x, y };
|
|
539
|
+
touchZoneRef.current = getZoneForPoint(x, y);
|
|
540
|
+
lastPanUpdateTsRef.current = 0;
|
|
541
|
+
onPanBeginJs(x, y, touchZoneRef.current);
|
|
542
|
+
}, [distanceBetweenTouches, getZoneForPoint, onPanBeginJs, onPinchBeginJs]);
|
|
543
|
+
const handleResponderMove = (0, react_1.useCallback)((event) => {
|
|
544
|
+
const touchCount = event.nativeEvent.touches.length;
|
|
545
|
+
activeTouchesRef.current = touchCount;
|
|
546
|
+
if (touchCount >= 2) {
|
|
547
|
+
const currentDistance = distanceBetweenTouches(event);
|
|
548
|
+
const startDistance = pinchStartDistanceRef.current;
|
|
549
|
+
if (!currentDistance || !startDistance)
|
|
550
|
+
return;
|
|
551
|
+
hasMovedRef.current = true;
|
|
552
|
+
const scale = currentDistance / startDistance;
|
|
553
|
+
onPinchUpdateJs(scale, Math.abs(1 - scale));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
511
556
|
const now = Date.now();
|
|
512
|
-
if (now -
|
|
557
|
+
if (now - lastPanUpdateTsRef.current < 33)
|
|
513
558
|
return;
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
(
|
|
543
|
-
|
|
544
|
-
|
|
559
|
+
lastPanUpdateTsRef.current = now;
|
|
560
|
+
const { locationX: x, locationY: y } = event.nativeEvent;
|
|
561
|
+
const w = Math.max(1, viewSize.current.width);
|
|
562
|
+
const h = Math.max(1, viewSize.current.height);
|
|
563
|
+
const dx = (x - touchStartRef.current.x) / w;
|
|
564
|
+
const dy = (y - touchStartRef.current.y) / h;
|
|
565
|
+
if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01)
|
|
566
|
+
hasMovedRef.current = true;
|
|
567
|
+
onPanUpdateJs(touchZoneRef.current, dx, dy, touchStartRef.current.x < w / 2);
|
|
568
|
+
}, [distanceBetweenTouches, onPanUpdateJs, onPinchUpdateJs]);
|
|
569
|
+
const handleResponderRelease = (0, react_1.useCallback)((event) => {
|
|
570
|
+
if (activeTouchesRef.current >= 2 || pinchStartDistanceRef.current != null) {
|
|
571
|
+
pinchStartDistanceRef.current = null;
|
|
572
|
+
onPinchEndJs();
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
onPanEndJs();
|
|
576
|
+
if (!hasMovedRef.current) {
|
|
577
|
+
const { locationX: x, locationY: y } = event.nativeEvent;
|
|
578
|
+
onEyeTapJs(x, y);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
activeTouchesRef.current = 0;
|
|
582
|
+
hasMovedRef.current = false;
|
|
583
|
+
}, [onEyeTapJs, onPanEndJs, onPinchEndJs]);
|
|
584
|
+
const handleResponderTerminate = (0, react_1.useCallback)(() => {
|
|
585
|
+
if (pinchStartDistanceRef.current != null || activeTouchesRef.current >= 2) {
|
|
586
|
+
pinchStartDistanceRef.current = null;
|
|
587
|
+
onPinchEndJs();
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
onPanEndJs();
|
|
591
|
+
}
|
|
592
|
+
activeTouchesRef.current = 0;
|
|
593
|
+
hasMovedRef.current = false;
|
|
594
|
+
}, [onPanEndJs, onPinchEndJs]);
|
|
545
595
|
const handleReady = (0, react_1.useCallback)(() => setIsReady(true), []);
|
|
546
|
-
|
|
596
|
+
const editorCamera = (0, react_1.useMemo)(() => ({
|
|
597
|
+
fov: 34,
|
|
598
|
+
position: [0, 1.55, 1.05],
|
|
599
|
+
near: 0.01,
|
|
600
|
+
far: 50,
|
|
601
|
+
}), []);
|
|
602
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.scene, children: [localUri ? ((0, jsx_runtime_1.jsx)(R3FWebGpuCanvas_1.R3FWebGpuCanvas, { style: react_native_1.StyleSheet.absoluteFill, camera: editorCamera, clearColor: "#0a0a0a", children: (0, jsx_runtime_1.jsx)(AvatarScene, { uri: localUri, morphState: morphState, onReady: handleReady }) })) : ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.loading, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingText, children: "Loading face\u2026" }) })), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.touchOverlay], pointerEvents: "box-only", onLayout: onLayout, collapsable: false, onStartShouldSetResponder: () => true, onStartShouldSetResponderCapture: () => true, onMoveShouldSetResponder: () => true, onMoveShouldSetResponderCapture: () => true, onResponderGrant: handleResponderGrant, onResponderMove: handleResponderMove, onResponderRelease: handleResponderRelease, onResponderTerminate: handleResponderTerminate, onResponderTerminationRequest: () => true })] }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.closeBtn, onPress: onClose, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.closeBtnText, children: "\u2715 Done squeezing" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.hint, children: "Drag to stretch \u00B7 Pinch lips \u00B7 Poke an eye \uD83D\uDC41" })] }));
|
|
547
603
|
}
|
|
548
604
|
const styles = react_native_1.StyleSheet.create({
|
|
549
605
|
container: { flex: 1, backgroundColor: '#0a0a0a' },
|
|
550
|
-
scene: { flex: 1 },
|
|
606
|
+
scene: { flex: 1, position: 'relative' },
|
|
607
|
+
touchOverlay: {
|
|
608
|
+
zIndex: 10,
|
|
609
|
+
elevation: 10,
|
|
610
|
+
backgroundColor: 'transparent',
|
|
611
|
+
},
|
|
551
612
|
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
552
613
|
loadingText: { color: '#97a3b5', fontSize: 14 },
|
|
553
614
|
closeBtn: {
|
|
554
615
|
position: 'absolute', top: 52, right: 16,
|
|
555
616
|
backgroundColor: '#6c63ff', borderRadius: 20,
|
|
556
617
|
paddingHorizontal: 16, paddingVertical: 8,
|
|
618
|
+
zIndex: 20,
|
|
619
|
+
elevation: 20,
|
|
557
620
|
},
|
|
558
621
|
closeBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
|
559
622
|
hint: {
|
|
560
623
|
position: 'absolute', bottom: 40, alignSelf: 'center',
|
|
561
624
|
color: '#97a3b5', fontSize: 13,
|
|
625
|
+
zIndex: 20,
|
|
562
626
|
},
|
|
563
627
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/** Web builds do not bundle the native face-squeeze GLB module. */
|
|
2
|
+
export declare const FACE_SQUEEZE_LOCAL_MODULE: null;
|
|
2
3
|
export interface FaceSqueezeEditorProps {
|
|
3
4
|
onClose: () => void;
|
|
5
|
+
avatarModule?: unknown;
|
|
4
6
|
}
|
|
5
7
|
export declare function FaceSqueezeEditor({ onClose }: FaceSqueezeEditorProps): import("react/jsx-runtime").JSX.Element;
|
|
6
8
|
export default FaceSqueezeEditor;
|
|
@@ -3,52 +3,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.FACE_SQUEEZE_LOCAL_MODULE = void 0;
|
|
4
4
|
exports.FaceSqueezeEditor = FaceSqueezeEditor;
|
|
5
5
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
exports.FACE_SQUEEZE_LOCAL_MODULE = require('../assets/face-squeeze-local.glb');
|
|
6
|
+
/** Web builds do not bundle the native face-squeeze GLB module. */
|
|
7
|
+
exports.FACE_SQUEEZE_LOCAL_MODULE = null;
|
|
9
8
|
function FaceSqueezeEditor({ onClose }) {
|
|
10
|
-
return ((0, jsx_runtime_1.jsx)(
|
|
9
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: styles.container, children: (0, jsx_runtime_1.jsxs)("div", { style: styles.card, children: [(0, jsx_runtime_1.jsx)("h2", { style: styles.title, children: "Face squeeze editor is mobile-only." }), (0, jsx_runtime_1.jsx)("p", { style: styles.subtitle, children: "This placeholder keeps web builds safe while preserving native functionality." }), (0, jsx_runtime_1.jsx)("button", { type: "button", style: styles.closeButton, onClick: onClose, children: "Close" })] }) }));
|
|
11
10
|
}
|
|
12
|
-
const styles =
|
|
11
|
+
const styles = {
|
|
13
12
|
container: {
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
backgroundColor: '#0f1115',
|
|
15
|
+
boxSizing: 'border-box',
|
|
16
|
+
display: 'flex',
|
|
14
17
|
flex: 1,
|
|
15
|
-
|
|
16
|
-
justifyContent:
|
|
17
|
-
backgroundColor: "#0f1115",
|
|
18
|
+
height: '100%',
|
|
19
|
+
justifyContent: 'center',
|
|
18
20
|
padding: 20,
|
|
21
|
+
width: '100%',
|
|
19
22
|
},
|
|
20
23
|
card: {
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
backgroundColor: '#171b23',
|
|
25
|
+
border: '1px solid #2d3340',
|
|
23
26
|
borderRadius: 12,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
backgroundColor: "#171b23",
|
|
27
|
+
boxSizing: 'border-box',
|
|
28
|
+
maxWidth: 460,
|
|
27
29
|
padding: 16,
|
|
28
|
-
|
|
30
|
+
width: '100%',
|
|
29
31
|
},
|
|
30
32
|
title: {
|
|
31
|
-
color:
|
|
33
|
+
color: '#f1f4f8',
|
|
32
34
|
fontSize: 18,
|
|
33
|
-
fontWeight:
|
|
35
|
+
fontWeight: 700,
|
|
36
|
+
margin: 0,
|
|
34
37
|
},
|
|
35
38
|
subtitle: {
|
|
36
|
-
color:
|
|
39
|
+
color: '#98a1b3',
|
|
37
40
|
fontSize: 14,
|
|
38
|
-
lineHeight:
|
|
41
|
+
lineHeight: '20px',
|
|
42
|
+
margin: '10px 0 0',
|
|
39
43
|
},
|
|
40
44
|
closeButton: {
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
backgroundColor: '#2e8f5b',
|
|
46
|
+
border: 0,
|
|
43
47
|
borderRadius: 8,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
paddingVertical: 8,
|
|
47
|
-
},
|
|
48
|
-
closeButtonText: {
|
|
49
|
-
color: "#ffffff",
|
|
48
|
+
color: '#ffffff',
|
|
49
|
+
cursor: 'pointer',
|
|
50
50
|
fontSize: 14,
|
|
51
|
-
fontWeight:
|
|
51
|
+
fontWeight: 600,
|
|
52
|
+
marginTop: 16,
|
|
53
|
+
padding: '8px 12px',
|
|
52
54
|
},
|
|
53
|
-
}
|
|
55
|
+
};
|
|
54
56
|
exports.default = FaceSqueezeEditor;
|
|
@@ -3,13 +3,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.RigidAccessory = RigidAccessory;
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
// @ts-nocheck
|
|
6
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- Accessory attachment walks arbitrary GLTF scene nodes from Three.js loader output. */
|
|
6
7
|
const drei_1 = require("@react-three/drei");
|
|
7
8
|
const react_1 = require("react");
|
|
8
9
|
const three_1 = require("three");
|
|
9
10
|
function RigidAccessory({ assetId, url, avatarScene, attachBone, offsetPosition, offsetRotation, scale = 1, isEditing = false, onPlacementChange, }) {
|
|
10
11
|
const { scene } = (0, drei_1.useGLTF)(url);
|
|
11
|
-
// Clone uniquely for this instance
|
|
12
|
+
// Clone uniquely for this instance — dispose geometry/materials on replacement or unmount
|
|
12
13
|
const clone = (0, react_1.useMemo)(() => scene.clone(true), [scene]);
|
|
14
|
+
(0, react_1.useEffect)(() => {
|
|
15
|
+
return () => {
|
|
16
|
+
clone.traverse((obj) => {
|
|
17
|
+
if (obj instanceof three_1.Mesh) {
|
|
18
|
+
obj.geometry?.dispose();
|
|
19
|
+
if (Array.isArray(obj.material))
|
|
20
|
+
obj.material.forEach((m) => m.dispose());
|
|
21
|
+
else
|
|
22
|
+
obj.material?.dispose();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
}, [clone]);
|
|
13
27
|
const [basePos, setBasePos] = (0, react_1.useState)(null);
|
|
14
28
|
(0, react_1.useEffect)(() => {
|
|
15
29
|
if (!avatarScene)
|
|
@@ -44,7 +58,8 @@ function RigidAccessory({ assetId, url, avatarScene, attachBone, offsetPosition,
|
|
|
44
58
|
clone.rotation.set(...offsetRotation);
|
|
45
59
|
}
|
|
46
60
|
if (scale !== undefined) {
|
|
47
|
-
|
|
61
|
+
const s = Math.max(0.001, scale);
|
|
62
|
+
clone.scale.set(s, s, s);
|
|
48
63
|
}
|
|
49
64
|
}, [basePos, offsetPosition, offsetRotation, scale, clone]);
|
|
50
65
|
const handleDragEnd = () => {
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.SkinnedClothing = SkinnedClothing;
|
|
37
37
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
38
38
|
// @ts-nocheck
|
|
39
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- SkeletonUtils.clone and GLTF skeleton rebinding expose untyped Three.js internals. */
|
|
39
40
|
const drei_1 = require("@react-three/drei");
|
|
40
41
|
const react_1 = require("react");
|
|
41
42
|
const THREE = __importStar(require("three"));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Converts a screen-space drag delta into a world-space translation delta,
|
|
4
|
+
* keeping the accessory in a plane facing the camera (bone-locked plane).
|
|
5
|
+
*/
|
|
6
|
+
export declare function screenDeltaToWorldDelta(camera: THREE.PerspectiveCamera, objectWorldPos: THREE.Vector3, dx: number, dy: number, viewportWidth: number, viewportHeight: number): THREE.Vector3;
|
|
7
|
+
/**
|
|
8
|
+
* Computes the rotation angle delta between two pointer-pair angles.
|
|
9
|
+
* Returns delta in radians, normalized to [-π, π].
|
|
10
|
+
*/
|
|
11
|
+
export declare function twoPointerAngleDelta(startAngle: number, currentAngle: number): number;
|
|
@@ -0,0 +1,68 @@
|
|
|
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 () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.screenDeltaToWorldDelta = screenDeltaToWorldDelta;
|
|
37
|
+
exports.twoPointerAngleDelta = twoPointerAngleDelta;
|
|
38
|
+
const THREE = __importStar(require("three"));
|
|
39
|
+
/**
|
|
40
|
+
* Converts a screen-space drag delta into a world-space translation delta,
|
|
41
|
+
* keeping the accessory in a plane facing the camera (bone-locked plane).
|
|
42
|
+
*/
|
|
43
|
+
function screenDeltaToWorldDelta(camera, objectWorldPos, dx, dy, viewportWidth, viewportHeight) {
|
|
44
|
+
const distance = objectWorldPos.distanceTo(camera.position);
|
|
45
|
+
const fovY = (camera.fov * Math.PI) / 180;
|
|
46
|
+
const worldHeight = 2 * distance * Math.tan(fovY / 2);
|
|
47
|
+
const worldWidth = worldHeight * (viewportWidth / viewportHeight);
|
|
48
|
+
const right = new THREE.Vector3();
|
|
49
|
+
const up = new THREE.Vector3();
|
|
50
|
+
camera.getWorldDirection(new THREE.Vector3()); // ensure matrix is fresh
|
|
51
|
+
right.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
|
|
52
|
+
up.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
|
|
53
|
+
return right
|
|
54
|
+
.multiplyScalar((dx / viewportWidth) * worldWidth)
|
|
55
|
+
.add(up.multiplyScalar((-dy / viewportHeight) * worldHeight));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Computes the rotation angle delta between two pointer-pair angles.
|
|
59
|
+
* Returns delta in radians, normalized to [-π, π].
|
|
60
|
+
*/
|
|
61
|
+
function twoPointerAngleDelta(startAngle, currentAngle) {
|
|
62
|
+
let delta = currentAngle - startAngle;
|
|
63
|
+
while (delta > Math.PI)
|
|
64
|
+
delta -= 2 * Math.PI;
|
|
65
|
+
while (delta < -Math.PI)
|
|
66
|
+
delta += 2 * Math.PI;
|
|
67
|
+
return delta;
|
|
68
|
+
}
|