'use strict'; var React = require('react'); var t...
Creato il: 22 maggio 2025
Creato il: 22 maggio 2025
'use strict';
var React = require('react');
var three = require('three');
var native = require('@react-three/fiber/native');
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** /
/ global Reflect, Promise, SuppressedError, Symbol */
var __assign = function() {
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
var EPSILON = 0.000001;
var STATE = {
NONE: 0,
ROTATE: 1,
DOLLY: 2,
};
var partialScope = {
camera: undefined,
enabled: true,
// We will override this later. A new vector ins't created here because it
// could cause problems when there is more than one controls on the screen
// (which could share the same target
object, if we created it here).
target: undefined,
minZoom: 0,
maxZoom: Infinity,
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to PI radians.
minPolarAngle: 0,
maxPolarAngle: Math.PI,
// How far you can orbit horizontally, upper and lower limits.
// If set, the interval [min, max] must be a sub-interval of
// [-2 PI, 2 PI], with (max - min < 2 PI)
minAzimuthAngle: -Infinity,
maxAzimuthAngle: Infinity,
// inertia
dampingFactor: 0.05,
enableZoom: true,
zoomSpeed: 1.0,
enableRotate: true,
rotateSpeed: 1.0,
enablePan: true,
panSpeed: 1.0,
ignoreQuickPress: false,
};
function createControls() {
var height = 0;
var scope = __assign(__assign({}, partialScope), { target: new three.Vector3(), onChange: function (event) { } });
var internals = {
moveStart: new three.Vector3(),
rotateStart: new three.Vector2(),
rotateEnd: new three.Vector2(),
rotateDelta: new three.Vector2(),
dollyStart: 0,
dollyEnd: 0,
panStart: new three.Vector2(),
panEnd: new three.Vector2(),
panDelta: new three.Vector2(),
panOffset: new three.Vector3(),
spherical: new three.Spherical(),
sphericalDelta: new three.Spherical(),
scale: 1,
state: STATE.NONE,
};
var functions = {
shouldClaimTouch: function (event) {
// If there's 1 touch it may not be related to orbit controls,
// therefore we delay "claiming" the touch, as on older devices this stops the
// event propagation to prevent bubbling.
// This option is disabled by default because on newer devices (I tested on
// Android 8+ and iOS 15+) this behavior is (happily) inexistent (the
// propagation only stops if the code explicitly tells it to do so).
// See https://github.com/TiagoCavalcante/r3f-native-orbitcontrols/issues/27
// Unfortunately, this feature may cause bugs in newer devices or browsers,
// where the first presses (quick or long) aren't detected.
// See https://github.com/TiagoCavalcante/r3f-native-orbitcontrols/issues/30
// See https://github.com/TiagoCavalcante/r3f-native-orbitcontrols/issues/31
// Therefore it is not recommended to enable it if you are targeting newer
// devices.
// There are other options to fix this behavior on older devices:
// 1. Use the events onTouchStart
, onTouchMove
, onTouchEnd
,
// onTouchCancel
from @react-three/fiber's Canvas
. I didn't choose this
// option because it seems to be slower than using the gesture responder
// system directly, and it would also make it harder to use these events
// in the Canvas
.
// 2. Add a transparent Plane
that covers the whole screen and use its
// touch events, which are exposed by @react-three/fiber. I didn't choose
// this option because it would hurt performance and just seems to be too
// hacky.
// 3. Use View
's onTouchStart
, onTouchMove
, etc. I think this would have
// the same behavior in older devices, but I still didn't test it. If you
// want me to test it, please just open an issue.
// Note that using @react-three/fiber's
// useThree().gl.domElement.addEventListener
doesn't work, just look at the
// code of the function:
// https://github.com/pmndrs/react-three-fiber/blob/6c830bd793cfd15d980299f2582f8a70cc53e30c/packages/fiber/src/native/Canvas.tsx#L83-L84
// Ideally, this should be fixed by implementing something like an
// addEventListener
-like in @react-three/fiber.
// I have suggested this feature here:
// https://github.com/pmndrs/react-three-fiber/issues/3173
if (!scope.ignoreQuickPress)
return true;
if (event.nativeEvent.touches.length === 1) {
var _a = event.nativeEvent.touches[0], x = _a.locationX, y = _a.locationY, t = _a.timestamp;
var dx = Math.abs(internals.moveStart.x - x);
var dy = Math.abs(internals.moveStart.y - y);
var dt = Math.pow(internals.moveStart.z - t, 2);
if (!internals.moveStart.length() ||
(dx * dt <= 1000 && dy * dt <= 1000)) {
internals.moveStart.set(x, y, t);
return false;
}
internals.moveStart.set(0, 0, 0);
}
return true;
},
handleTouchStartRotate: function (event) {
if (event.nativeEvent.touches.length === 1) {
internals.rotateStart.set(event.nativeEvent.touches[0].locationX, event.nativeEvent.touches[0].locationY);
}
else if (event.nativeEvent.touches.length === 2) {
var x = 0.5 *
(event.nativeEvent.touches[0].locationX +
event.nativeEvent.touches[1].locationX);
var y = 0.5 *
(event.nativeEvent.touches[0].locationY +
event.nativeEvent.touches[1].locationY);
internals.rotateStart.set(x, y);
}
},
handleTouchStartDolly: function (event) {
// Ensures this isn't undefined.
if (event.nativeEvent.touches.length === 2) {
var dx = event.nativeEvent.touches[0].locationX -
event.nativeEvent.touches[1].locationX;
var dy = event.nativeEvent.touches[0].locationY -
event.nativeEvent.touches[1].locationY;
var distance = Math.sqrt(dx * dx + dy * dy);
internals.dollyStart = distance;
}
},
handleTouchStartPan: function (event) {
if (event.nativeEvent.touches.length === 1) {
internals.panStart.set(event.nativeEvent.touches[0].locationX, event.nativeEvent.touches[0].locationY);
}
else if (event.nativeEvent.touches.length === 2) {
var x = 0.5 *
(event.nativeEvent.touches[0].locationX +
event.nativeEvent.touches[1].locationX);
var y = 0.5 *
(event.nativeEvent.touches[0].locationY +
event.nativeEvent.touches[1].locationY);
internals.panStart.set(x, y);
}
},
handleTouchStartDollyPan: function (event) {
if (scope.enableZoom)
this.handleTouchStartDolly(event);
if (scope.enablePan)
this.handleTouchStartPan(event);
},
onTouchStart: function (event) {
switch (event.nativeEvent.touches.length) {
case STATE.ROTATE:
if (!scope.enableRotate)
return;
this.handleTouchStartRotate(event);
internals.state = STATE.ROTATE;
break;
case STATE.DOLLY:
if (!scope.enableZoom && !scope.enablePan)
return;
this.handleTouchStartDollyPan(event);
internals.state = STATE.DOLLY;
break;
default:
internals.state = STATE.NONE;
}
},
rotateLeft: function (angle) {
internals.sphericalDelta.theta -= angle;
},
rotateUp: function (angle) {
internals.sphericalDelta.phi -= angle;
},
handleTouchMoveRotate: function (event) {
if (event.nativeEvent.touches.length === 1) {
internals.rotateEnd.set(event.nativeEvent.locationX, event.nativeEvent.locationY);
}
else if (event.nativeEvent.touches.length === 2) {
var x = 0.5 *
(event.nativeEvent.touches[0].locationX +
event.nativeEvent.touches[1].locationX);
var y = 0.5 *
(event.nativeEvent.touches[0].locationY +
event.nativeEvent.touches[1].locationY);
internals.rotateEnd.set(x, y);
}
internals.rotateDelta
.subVectors(internals.rotateEnd, internals.rotateStart)
.multiplyScalar(scope.rotateSpeed);
// Avoid division by 0.
if (height) {
// yes, height
this.rotateLeft((2 * Math.PI * internals.rotateDelta.x) / height);
this.rotateUp((2 * Math.PI * internals.rotateDelta.y) / height);
}
internals.rotateStart.copy(internals.rotateEnd);
},
dollyOut: function (dollyScale) {
internals.scale /= dollyScale;
},
handleTouchMoveDolly: function (event) {
// Ensures this isn't undefined.
if (event.nativeEvent.touches.length === 2) {
var dx = event.nativeEvent.touches[0].locationX -
event.nativeEvent.touches[1].locationX;
var dy = event.nativeEvent.touches[0].locationY -
event.nativeEvent.touches[1].locationY;
var distance = Math.sqrt(dx * dx + dy * dy);
internals.dollyEnd = distance;
this.dollyOut(Math.pow(internals.dollyEnd / internals.dollyStart, scope.zoomSpeed));
internals.dollyStart = internals.dollyEnd;
}
},
panLeft: function (distance, objectMatrix) {
var v = new three.Vector3();
v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix
v.multiplyScalar(-distance);
internals.panOffset.add(v);
},
panUp: function (distance, objectMatrix) {
var v = new three.Vector3();
v.setFromMatrixColumn(objectMatrix, 1);
v.multiplyScalar(distance);
internals.panOffset.add(v);
},
pan: function (deltaX, deltaY) {
if (!scope.camera)
return;
var position = scope.camera.position;
var targetDistance = position.clone().sub(scope.target).length();
var linearSquare =
// interpolate between x and x²
function (x) { return x + (1 - Math.exp(-x / 10000)) * (x * x - x + 1 / 4); };
var distanceScale = scope.camera
.isPerspectiveCamera
? // half of the fov is center to top of screen
scope.camera.fov / 2
: // scale the zoom speed by a factor of 300
(1 / linearSquare(scope.camera.zoom)) * scope.zoomSpeed * 300;
targetDistance *= Math.tan((distanceScale * Math.PI) / 180.0);
// Avoid division by 0.
if (height) {
// we use only height here so aspect ratio does not distort speed
this.panLeft((2 * deltaX * targetDistance) / height, scope.camera.matrix);
this.panUp((2 * deltaY * targetDistance) / height, scope.camera.matrix);
}
},
handleTouchMovePan: function (event) {
if (event.nativeEvent.touches.length === 1) {
internals.panEnd.set(event.nativeEvent.locationX, event.nativeEvent.locationY);
}
else if (event.nativeEvent.touches.length === 2) {
var x = 0.5 *
(event.nativeEvent.touches[0].locationX +
event.nativeEvent.touches[1].locationX);
var y = 0.5 *
(event.nativeEvent.touches[0].locationY +
event.nativeEvent.touches[1].locationY);
internals.panEnd.set(x, y);
}
else {
return;
}
internals.panDelta
.subVectors(internals.panEnd, internals.panStart)
.multiplyScalar(scope.panSpeed);
this.pan(internals.panDelta.x, internals.panDelta.y);
internals.panStart.copy(internals.panEnd);
},
handleTouchMoveDollyPan: function (event) {
if (scope.enableZoom)
this.handleTouchMoveDolly(event);
if (scope.enablePan)
this.handleTouchMovePan(event);
},
onTouchMove: function (event) {
switch (internals.state) {
case STATE.ROTATE:
if (!scope.enableRotate)
return;
this.handleTouchMoveRotate(event);
update();
break;
case STATE.DOLLY:
if (!scope.enableZoom && !scope.enablePan)
return;
this.handleTouchMoveDollyPan(event);
update();
break;
default:
internals.state = STATE.NONE;
}
},
};
var update = (function () {
var offset = new three.Vector3();
var lastPosition = new three.Vector3();
var lastQuaternion = new three.Quaternion();
var twoPI = 2 * Math.PI;
return function () {
if (!scope.camera)
return;
var position = scope.camera.position;
// so camera.up is the orbit axis
var quat = new three.Quaternion().setFromUnitVectors(scope.camera.up, new three.Vector3(0, 1, 0));
var quatInverse = quat.clone().invert();
offset.copy(position).sub(scope.target);
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat);
// angle from z-axis around y-axis
internals.spherical.setFromVector3(offset);
internals.spherical.theta +=
internals.sphericalDelta.theta * scope.dampingFactor;
internals.spherical.phi +=
internals.sphericalDelta.phi * scope.dampingFactor;
// restrict theta to be between desired limits
var min = scope.minAzimuthAngle;
var max = scope.maxAzimuthAngle;
if (isFinite(min) && isFinite(max)) {
if (min < -Math.PI)
min += twoPI;
else if (min > Math.PI)
min -= twoPI;
if (max < -Math.PI)
max += twoPI;
else if (max > Math.PI)
max -= twoPI;
if (min <= max) {
internals.spherical.theta = Math.max(min, Math.min(max, internals.spherical.theta));
}
else {
internals.spherical.theta =
internals.spherical.theta > (min + max) / 2
? Math.max(min, internals.spherical.theta)
: Math.min(max, internals.spherical.theta);
}
}
// restrict phi to be between desired limits
internals.spherical.phi = Math.max(scope.minPolarAngle + EPSILON, Math.min(scope.maxPolarAngle - EPSILON, internals.spherical.phi));
if (scope.camera.isPerspectiveCamera) {
internals.spherical.radius *= internals.scale;
}
else {
scope.camera.zoom = Math.max(Math.min(scope.camera.zoom / (internals.scale * scope.zoomSpeed), scope.maxZoom), scope.minZoom);
scope.camera.updateProjectionMatrix();
}
// restrict radius to be between desired limits
internals.spherical.radius = Math.max(scope.minZoom, Math.min(scope.maxZoom, internals.spherical.radius));
// move target to panned location
scope.target.addScaledVector(internals.panOffset, scope.dampingFactor);
offset.setFromSpherical(internals.spherical);
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse);
position.copy(scope.target).add(offset);
scope.camera.lookAt(scope.target);
internals.sphericalDelta.theta *= 1 - scope.dampingFactor;
internals.sphericalDelta.phi *= 1 - scope.dampingFactor;
internals.panOffset.multiplyScalar(1 - scope.dampingFactor);
internals.scale = 1;
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPSILON
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (lastPosition.distanceToSquared(scope.camera.position) > EPSILON ||
8 * (1 - lastQuaternion.dot(scope.camera.quaternion)) > EPSILON) {
native.invalidate();
scope.onChange({ target: scope });
lastPosition.copy(scope.camera.position);
lastQuaternion.copy(scope.camera.quaternion);
}
};
})();
return {
scope: scope,
functions: __assign(__assign({}, functions), { update: update }),
events: {
// Equivalent to componentDidMount.
onLayout: function (event) {
height = event.nativeEvent.layout.height;
},
// See https://reactnative.dev/docs/gesture-responder-system
onStartShouldSetResponder: function (event) {
// On some devices this fires only for 2+ touches.
if (!scope.enabled || !functions.shouldClaimTouch(event))
return false;
functions.onTouchStart(event);
return true;
},
onMoveShouldSetResponder: function (event) {
// And on the same devices this fires only for 1 touch.
if (!scope.enabled || !functions.shouldClaimTouch(event))
return false;
functions.onTouchStart(event);
return true;
},
onResponderMove: function (event) {
if (internals.state !== event.nativeEvent.touches.length) {
functions.onTouchStart(event);
}
functions.onTouchMove(event);
},
onResponderRelease: function () {
internals.state = STATE.NONE;
},
},
};
}
function OrbitControls(_a) {
var controls = _a.controls, props = __rest(_a, ["controls"]);
var camera = native.useThree(function (state) { return state.camera; });
React.useEffect(function () {
if (camera.isPerspectiveCamera ||
camera.isOrthographicCamera) {
controls.scope.camera = camera;
}
else {
throw new Error("The camera must be a PerspectiveCamera or OrthographicCamera to orbit controls work");
}
}, [camera]);
React.useEffect(function () {
for (var prop in props) {
controls.scope[prop] =
props[prop];
}
}, [props]);
native.useFrame(controls.functions.update, -1);
return null;
}
function useControls() {
var controls = React.useMemo(function () { return createControls(); }, []);
return [
function (props) { return (React.createElement(OrbitControls, __assign({ controls: controls }, props))); },
controls.events,
];
}
module.exports = useControls;
Если выпиливаю этот код то объекты выделяются с первого раза, если он работает то только через раз. Что надо поменять в коде?
Причина «каждого второго» тапа в том, что PanResponder внутри OrbitControls забирает себе первое одиночное нажатие (он объявляется «главным обработчиком» в onStartShouldSetResponder
). Пока управление у него, событие не доходит до raycaster’а, и меш не выделяется. На следующем тапе респондер уже освобождён — объект выделяется, и создаётся впечатление, будто срабатывает «через раз».
Самый простой способ — не перехватывать быстрый одиночный тап, а забирать управление только когда жест точно превратился в вращение/масштабирование (или при 2-пальцевом касании).
ignoreQuickPress
diff// partialScope - ignoreQuickPress: false, + ignoreQuickPress: true,
или при использовании:
jsxconst [OrbitControls, bind] = useControls(); <OrbitControls {...bind} ignoreQuickPress />
Флаг уже есть в коде: когда ignoreQuickPress === true
, функция
shouldClaimTouch
пропускает быстрый одиночный тап, и он доходит до объекта.
Если нужен более избирательный контроль:
diffevents: { onStartShouldSetResponder: (e) => { - if (!scope.enabled || !functions.shouldClaimTouch(e)) return false; + if (!scope.enabled) return false; + // одиночный «тик» не перехватываем — пусть дойдёт до raycaster + if (e.nativeEvent.touches.length === 1) return false; + // 2-пальцевое касание или явное движение — + // решаем по прежнему алгоритму + if (!functions.shouldClaimTouch(e)) return false; functions.onTouchStart(e); return true; }, ... }
Такая правка тоже оставит одиночные быстрые нажатия «прозрачными» для OrbitControls, но как только палец начнёт двигаться (или появится второй палец) — респондер перехватится, и управление камерой продолжит работать как прежде.
После любой из этих правок объекты будут выделяться с первого же нажатия, а жесты OrbitControls сохранятся.
Все сделал. Даже если так пишу - все равно 2 клика пальцем нужно
textonStartShouldSetResponder: function (event) { // // On some devices this fires only for 2+ touches. // if (!scope.enabled || !functions.shouldClaimTouch(event)) // return false; // functions.onTouchStart(event); // return true; return false; if (!scope.enabled) return false; // одиночный «тик» не перехватываем — пусть дойдёт до raycaster if (event.nativeEvent.touches.length === 1) return false; // 2-пальцевое касание или явное движение — // решаем по прежнему алгоритму if (!functions.shouldClaimTouch(event)) return false; functions.onTouchStart(event); return true; },
Первый «чужой» тап теперь приходит не в onStartShouldSetResponder
, а в onMoveShouldSetResponder
.
Даже если палец совсем не двигается, React Native всё-равно пытается спросить «а может быть это пан?», вызывает onMoveShouldSetResponder
, и OrbitControls снова забирает респондер, поэтому ваш mesh ловится только на второй тап.
Нужно одинаково фильтровать оба метода-решателя (и start, и move). Сделайте так:
diffevents: { onStartShouldSetResponder: (e) => { - if (!scope.enabled || !functions.shouldClaimTouch(e)) return false; - functions.onTouchStart(e); - return true; + if (!scope.enabled) return false; + // 1-палец-быстрый — пропускаем к raycaster’у + if (e.nativeEvent.touches.length === 1) return false; + if (!functions.shouldClaimTouch(e)) return false; + functions.onTouchStart(e); + return true; }, onMoveShouldSetResponder: (e) => { - if (!scope.enabled || !functions.shouldClaimTouch(e)) return false; - functions.onTouchStart(e); - return true; + if (!scope.enabled) return false; + // Пока один палец — не перехватываем + if (e.nativeEvent.touches.length === 1) return false; + if (!functions.shouldClaimTouch(e)) return false; + functions.onTouchStart(e); + return true; }, onResponderMove: (e) => { /* без изменений */ }, ... }
или ещё короче — просто добавьте флаг ignoreQuickPress
при инициализации:
jsxconst [OrbitControls, bind] = useControls(); <OrbitControls ignoreQuickPress {...bind} />
В исходном коде ignoreQuickPress
заставляет shouldClaimTouch
возвращать false
для первого одиночного тапа, а мы теперь не перехватываем его и в onMoveShouldSetResponder
, так что респондер остаётся свободным и объект выделяется с первого раза.
Хорошо работает но перестало работать вращение
Перестало вращаться потому что OrbitControls больше никогда становится респондентом при одиночном касании:
вы возвращаете false
и в onStartShouldSetResponder
, и в onMoveShouldSetResponder
, поэтому даже когда палец начинает двигаться, управление остаётся у родительского View, а камера не получает событий “drag”.
Достаточно фильтровать только старт, а onMoveShouldSetResponder
— оставить как в оригинале (или разрешать, когда shouldClaimTouch
скажет «да»). Тогда:
shouldClaimTouch
вернёт true
(после пары пикселей / миллисекунд) и OrbitControls заберёт респондер → вращение снова работает;diffevents: { onStartShouldSetResponder: (e) => { if (!scope.enabled) return false; // одиночный «тап» не перехватываем if (e.nativeEvent.touches.length === 1) return false; if (!functions.shouldClaimTouch(e)) return false; functions.onTouchStart(e); return true; }, // ← верните исходную реализацию (или вот так) onMoveShouldSetResponder: (e) => { if (!scope.enabled || !functions.shouldClaimTouch(e)) return false; functions.onTouchStart(e); return true; }, onResponderMove: functions.onTouchMove, onResponderRelease: () => { internals.state = STATE.NONE; }, }
jsxconst [OrbitControls, bind] = useControls(); <OrbitControls ignoreQuickPress {...bind} />
ignoreQuickPress
уже реализует описанную логику:
он пропускает первый короткий одиночный тап, а при дальнейшем движении того же пальца shouldClaimTouch
отдаёт управление OrbitControls, так что и выделение, и вращение работают одновременно.