@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
401 lines • 17.8 kB
JavaScript
import { Euler, Quaternion, Ray, Vector3 } from "three";
// #endregion
// #region Constraints
/** Snaps the follow object's world position to a uniform grid. Resolution ≤ 0 is a no-op.
* For XZ-plane mode, use {@link GrabPointPlaneConstraint} instead (it handles both
* plane-clamping and optional grid-snapping on the plane). */
export class GridSnapConstraint {
snapGridResolution;
constructor(snapGridResolution) {
this.snapGridResolution = snapGridResolution;
}
apply(followObject) {
const r = this.snapGridResolution;
if (r <= 0)
return;
const wp = followObject.worldPosition;
wp.x = Math.round(wp.x / r) * r;
wp.y = Math.round(wp.y / r) * r;
wp.z = Math.round(wp.z / r) * r;
followObject.worldPosition = wp;
followObject.updateMatrix();
}
}
/** Projects the grabbed attachment point back onto a plane after position is resolved,
* with optional grid snapping on that plane.
* Owned by the strategy that requires plane-clamping (e.g. {@link XZPlaneDragStrategy}). */
export class GrabPointPlaneConstraint {
/** Plane to project the grabbed point onto. Typically the strategy's fixed drag plane. */
plane;
/** Attachment point in dragged-object local space. Set once at drag start; the handler
* mutates this Vector3 in-place so updates are reflected automatically. */
hitPointInLocalSpace = null;
/** Grid snap resolution on the plane. 0 = projection only, no snapping. */
snapResolution = 0;
constructor(plane) {
this.plane = plane;
}
init(ctx) {
this.hitPointInLocalSpace = ctx.hitPointInLocalSpace;
}
apply(followObject) {
if (!this.hitPointInLocalSpace)
return;
// Compute the grabbed point in world space.
const grabbedWP = this.hitPointInLocalSpace.clone();
followObject.localToWorld(grabbedWP);
// Preserve the constant pivot↔grab-point offset.
const followWP = followObject.worldPosition;
const ox = followWP.x - grabbedWP.x;
const oy = followWP.y - grabbedWP.y;
const oz = followWP.z - grabbedWP.z;
// Optionally snap the grabbed point to the grid first.
let snapped = false;
const r = this.snapResolution;
if (r > 0) {
grabbedWP.x = Math.round(grabbedWP.x / r) * r;
grabbedWP.y = Math.round(grabbedWP.y / r) * r;
grabbedWP.z = Math.round(grabbedWP.z / r) * r;
snapped = true;
}
// Project back onto the plane so the grabbed point stays on the drag surface.
const bx = grabbedWP.x, by = grabbedWP.y, bz = grabbedWP.z;
this.plane.projectPoint(grabbedWP, grabbedWP);
const projectionMoved = Math.abs(grabbedWP.x - bx) + Math.abs(grabbedWP.y - by) + Math.abs(grabbedWP.z - bz) > 1e-5;
if (snapped || projectionMoved) {
// Restore follow-object offset.
followWP.set(grabbedWP.x + ox, grabbedWP.y + oy, grabbedWP.z + oz);
followObject.worldPosition = followWP;
followObject.updateMatrix();
}
}
}
/** Locks the follow object's world rotation to the quaternion captured at drag-start. */
export class KeepRotationConstraint {
_savedQuat = new Quaternion();
init(ctx) {
this._savedQuat.copy(ctx.gameObject.worldQuaternion);
}
apply(followObject) {
followObject.worldQuaternion = this._savedQuat;
followObject.updateMatrix();
}
}
/** Locks the follow object's world scale to the value captured at drag-start. */
export class KeepScaleConstraint {
_savedScale = new Vector3(1, 1, 1);
init(ctx) {
this._savedScale.copy(ctx.gameObject.worldScale);
}
apply(followObject) {
followObject.worldScale = this._savedScale;
followObject.updateMatrix();
}
}
/**
* Clamps the world scale of the dragged object to a [min, max] range.
*
* When `relativeToInitialScale` is `true` (default), `min` and `max` are treated as
* multipliers of the object's world scale captured at drag start — for example, `min=0.01`
* means the object cannot shrink below 1% of its original size.
*
* When `relativeToInitialScale` is `false`, `min` and `max` are applied directly as
* absolute world-space scale values to each axis independently.
*
* @example Prevent negative / near-zero scale with default relative mode:
* ```ts
* new ScaleLimitConstraint(0.01, 10000, true, object.worldScale.clone());
* ```
*/
export class ScaleLimitConstraint {
min;
max;
relativeToInitialScale;
/**
* @param min Lower bound. Relative mode: fraction of initial scale. Raw mode: absolute world-scale per axis.
* @param max Upper bound. Relative mode: fraction of initial scale. Raw mode: absolute world-scale per axis.
* @param relativeToInitialScale When `true`, clamp is relative to `initialWorldScale`. When `false`, each axis is clamped independently.
* @param initialWorldScale Reference to the object's world scale captured at drag start. Required for relative mode.
*/
_initialWorldScale = new Vector3(1, 1, 1);
constructor(min, max, relativeToInitialScale = true) {
this.min = min;
this.max = max;
this.relativeToInitialScale = relativeToInitialScale;
}
init(ctx) {
const ws = ctx.initialWorldScale ?? ctx.gameObject.worldScale;
this._initialWorldScale.copy(ws);
}
apply(followObject) {
const ws = followObject.worldScale;
if (this.relativeToInitialScale) {
// Derive the uniform scale ratio from the magnitude of the current world scale
// relative to the initial world scale magnitude.
const initLen = this._initialWorldScale.length();
if (initLen < 1e-10)
return;
const ratio = ws.length() / initLen;
const clamped = Math.max(this.min, Math.min(this.max, ratio));
if (Math.abs(clamped - ratio) > 1e-9) {
ws.copy(this._initialWorldScale).multiplyScalar(clamped);
followObject.worldScale = ws;
followObject.updateMatrix();
}
}
else {
// Absolute per-axis clamp.
const cx = Math.max(this.min, Math.min(this.max, ws.x));
const cy = Math.max(this.min, Math.min(this.max, ws.y));
const cz = Math.max(this.min, Math.min(this.max, ws.z));
if (cx !== ws.x || cy !== ws.y || cz !== ws.z) {
ws.set(cx, cy, cz);
followObject.worldScale = ws;
followObject.updateMatrix();
}
}
}
}
/**
* Flags controlling which rotation axes are frozen by {@link FixedRotationAxesConstraint}.
* Values can be combined with the bitwise OR operator (`|`).
* @example Freeze X and Z: `RotationAxis.X | RotationAxis.Z`
*/
export var RotationAxis;
(function (RotationAxis) {
RotationAxis[RotationAxis["X"] = 1] = "X";
RotationAxis[RotationAxis["Y"] = 2] = "Y";
RotationAxis[RotationAxis["Z"] = 4] = "Z";
})(RotationAxis || (RotationAxis = {}));
/**
* Freezes individual rotation axes (X, Y, and/or Z) of the dragged object.
* The locked axis values are captured once at construction time and restored every frame.
*
* Set {@link useLocalSpace} to `true` to lock axes in the object's local space;
* leave it `false` (default) to lock axes in world space.
*
* @example Lock Y-axis rotation in world space:
* ```ts
* const c = new FixedRotationAxesConstraint(RotationAxis.Y, false);
* // init is called automatically by DragControls, or call manually: c.init(ctx);
* ```
*/
export class FixedRotationAxesConstraint {
frozenAxes;
useLocalSpace;
_startEuler = new Euler();
_eulerCache = new Euler();
/**
* @param frozenAxes Bitfield of {@link RotationAxis} values indicating which axes to lock.
* @param useLocalSpace When `true`, axes are locked in the object's local space; otherwise world space.
*/
constructor(frozenAxes, useLocalSpace = false) {
this.frozenAxes = frozenAxes;
this.useLocalSpace = useLocalSpace;
}
init(ctx) {
const q = this.useLocalSpace
? ctx.gameObject.quaternion
: ctx.gameObject.worldQuaternion;
this._startEuler.setFromQuaternion(q, 'XYZ');
}
apply(followObject) {
const frozenAxes = this.frozenAxes;
if (frozenAxes === 0)
return;
// Read the current rotation in the chosen space.
const current = this.useLocalSpace
? followObject.quaternion
: followObject.worldQuaternion;
this._eulerCache.setFromQuaternion(current, 'XYZ');
// Overwrite the locked components with their start values.
if (frozenAxes & RotationAxis.X)
this._eulerCache.x = this._startEuler.x;
if (frozenAxes & RotationAxis.Y)
this._eulerCache.y = this._startEuler.y;
if (frozenAxes & RotationAxis.Z)
this._eulerCache.z = this._startEuler.z;
// Recompose and write back.
_tmpQuat.setFromEuler(this._eulerCache);
if (this.useLocalSpace) {
followObject.quaternion.copy(_tmpQuat);
}
else {
followObject.worldQuaternion = _tmpQuat;
}
followObject.updateMatrix();
}
}
/** Shared scratch quaternion for {@link FixedRotationAxesConstraint}. */
const _tmpQuat = new Quaternion();
/**
* Constrains a dragged object to rotate only around a fixed world-space axis.
* Uses swing-twist decomposition to extract only the twist component around
* the given axis from the delta rotation since drag start, discarding any
* perpendicular swing that would tilt the object.
*
* This is the correct constraint for XZ-plane dragging when the parent is
* rotated — it restricts the object to yaw around the plane normal regardless
* of how the plane is oriented in world space.
*/
export class AxisRotationConstraint {
_startQuat = new Quaternion();
_axis;
constructor(axis) {
this._axis = axis.clone().normalize();
}
init(ctx) {
this._startQuat.copy(ctx.gameObject.worldQuaternion);
}
apply(followObject) {
const q = followObject.worldQuaternion;
// q_delta = q_current * q_start⁻¹ (motion applied since drag start)
_axisQ0.copy(this._startQuat).invert();
_axisQ1.multiplyQuaternions(q, _axisQ0);
// Swing-twist decomposition: project the vector part of q_delta onto the twist axis.
const nx = this._axis.x, ny = this._axis.y, nz = this._axis.z;
const dot = _axisQ1.x * nx + _axisQ1.y * ny + _axisQ1.z * nz;
const px = dot * nx, py = dot * ny, pz = dot * nz;
const qw = _axisQ1.w;
// twist = normalize(px, py, pz, qw)
const len = Math.sqrt(px * px + py * py + pz * pz + qw * qw);
if (len < 1e-10) {
// Degenerate: delta is a pure swing ~180° perpendicular to the axis.
// No twist component — snap back to start orientation.
followObject.worldQuaternion = this._startQuat;
}
else {
const inv = 1 / len;
_axisQ0.set(px * inv, py * inv, pz * inv, qw * inv);
// q_result = twist * q_start
_axisQ1.multiplyQuaternions(_axisQ0, this._startQuat);
followObject.worldQuaternion = _axisQ1;
}
followObject.updateMatrix();
}
}
/** Scratch quaternions for {@link AxisRotationConstraint}. */
const _axisQ0 = new Quaternion();
const _axisQ1 = new Quaternion();
/** Locks the follow object's signed distance from a plane to a value set at drag start.
* Owned by {@link XZPlaneDragStrategy} to prevent slow Y drift in the side-view fallback path.
* Immune to grab-point projection errors, stale matrixWorld, and epsilon guards.
*
* When {@link boundsBottomSignedDistFromPivot} is non-zero (set by {@link MultiTouchDragHandler}
* for XZPlane scaling), the effective locked height is shifted each frame so the **bottom of the
* object's bounds** stays at the height captured at drag start instead of the pivot. */
export class PlaneHeightLockConstraint {
plane;
_lockedHeight = 0;
_boundsBottomSignedDistFromPivot = 0;
/** Current pinch-scale ratio, updated each frame by {@link MultiTouchDragHandler}. */
currentScaleRatio = 1;
constructor(plane) {
this.plane = plane;
}
init(ctx) {
const wp = ctx.gameObject.worldPosition;
this._lockedHeight = this.plane.normal.dot(wp) + this.plane.constant;
// When bounds and initial scale are provided (two-pointer drag), configure the
// constraint to keep the bounds bottom at a constant height while pinch-scaling.
// bounds.min.y is in local space at scale=1; multiplied by initialWorldScaleY it gives
// the world-space signed-distance offset from pivot to the bottom of the bounds.
this._boundsBottomSignedDistFromPivot = (ctx.boundsAtScaleOne !== null && ctx.initialWorldScale !== null)
? ctx.boundsAtScaleOne.min.y * ctx.initialWorldScale.y
: 0;
this.currentScaleRatio = 1;
}
apply(followObject) {
// When scaling (_boundsBottomSignedDistFromPivot != 0), shift the target height so the
// bounds bottom stays fixed rather than the pivot. At ratio == 1 the correction is 0.
const targetHeight = this._lockedHeight
+ this._boundsBottomSignedDistFromPivot * (1 - this.currentScaleRatio);
const wp = followObject.worldPosition;
const delta = targetHeight - (this.plane.normal.dot(wp) + this.plane.constant);
if (Math.abs(delta) > 1e-9) {
wp.addScaledVector(this.plane.normal, delta);
followObject.worldPosition = wp;
followObject.updateMatrix();
}
}
}
// #endregion
/**
* Used by {@link SnapToSurfacesDragStrategy} for two-pointer (multi-touch) drags.
* Casts a downward ray each frame from the follow object's world position and adjusts
* it so the dragged object's bounding-box contact face rests on the detected surface.
*
* Only injected into the constraint pipeline for multi-touch; the single-pointer path
* continues to use {@link SnapToSurfacesDragStrategy.update} (pointer ray + drag plane
* intersection) unchanged.
*/
export class SnapToSurfaceConstraint {
_context;
_gameObject = null;
_boundsAtScaleOne = null;
// Per-frame scratch vectors — avoids per-frame allocations.
_t1 = new Vector3();
_t2 = new Vector3();
_t3 = new Vector3();
_t4 = new Vector3();
constructor(_context) {
this._context = _context;
}
init(ctx) {
this._gameObject = ctx.gameObject;
this._boundsAtScaleOne = ctx.boundsAtScaleOne;
}
apply(followObject) {
if (!this._gameObject || !this._boundsAtScaleOne)
return;
// Cast a downward ray from 0.5 units above the follow object's current world position.
// The small upward offset avoids self-intersection when the object sits on a surface.
// World-Y down is used; it finds the nearest surface below the follow object.
const currentWP = followObject.worldPosition;
this._t1.set(currentWP.x, currentWP.y + 0.5, currentWP.z);
const ray = new Ray(this._t1, _snapToSurfaceDown);
const hits = this._context.physics.raycastFromRay(ray, {
testObject: (o) => o !== followObject && o !== this._gameObject
});
if (hits.length === 0)
return;
const hit = hits[0];
if (!hit.face)
return;
// Compute world-space surface normal.
this._t2.copy(hit.normal ?? hit.face.normal).applyQuaternion(hit.object.worldQuaternion);
const worldNormal = this._t2;
// Compute the bounds contact face in local-scale-1 space — mirrors the anchor
// calculation in SnapToSurfacesDragStrategy.update() (needsAnchorUpdate block).
this._boundsAtScaleOne.getCenter(this._t3);
this._boundsAtScaleOne.getSize(this._t4);
// Contact face: move center toward the surface by half the extent along worldNormal.
this._t3.sub(this._t4.multiplyScalar(0.5).multiply(worldNormal));
const contactAlongNormal = this._t3.dot(worldNormal);
// Anchor in local-scale-1 space. For multi-touch hitPointInLocalSpace is (0,0,0), so
// the anchor simplifies to: worldNormal * contactAlongNormal.
this._t3.copy(worldNormal).multiplyScalar(contactAlongNormal);
// Transform anchor to world space via the follow object's current matrix.
// This correctly accounts for the object's actual rotation and pinch-scale.
this._t4.copy(this._t3);
followObject.localToWorld(this._t4); // _t4 is now anchorWP
// Shift follow object so anchorWP coincides with hit.point:
// newFollowWP = currentWP + (hit.point − anchorWP)
this._t1.copy(hit.point).sub(this._t4).add(currentWP);
followObject.worldPosition = this._t1;
followObject.updateMatrix();
}
}
/** Shared downward direction vector for {@link SnapToSurfaceConstraint}. */
const _snapToSurfaceDown = new Vector3(0, -1, 0);
// #region Utilities
/**
* Runs the constraint pipeline — applies each constraint to followObject in order.
* Shared by {@link DragPointerHandler} and {@link MultiTouchDragHandler}.
*/
export function applyFollowObjectConstraints(followObject, constraints) {
for (const c of constraints)
c.apply(followObject);
}
// #endregion
//# sourceMappingURL=DragControlsConstraints.js.map