@neuroequality/neuroadapt-vr
Version:
WebXR safe spaces and comfort zones for NeuroAdapt SDK
366 lines (361 loc) • 11.3 kB
JavaScript
import { EventEmitter } from 'eventemitter3';
class SafeZoneManager extends EventEmitter {
constructor(preferences) {
super();
this.zones = /* @__PURE__ */ new Map();
this.currentPosition = { x: 0, y: 0, z: 0 };
this.proximityCheckInterval = null;
this.lastWarnings = /* @__PURE__ */ new Set();
this.preferences = preferences;
this.startProximityMonitoring();
}
createSafeZone(config) {
const id = this.generateZoneId();
const zone = {
id,
...config
};
this.zones.set(id, zone);
this.emit("safe-zone-created", zone);
return zone;
}
removeSafeZone(id) {
const removed = this.zones.delete(id);
if (removed) {
this.emit("safe-zone-removed", { id });
this.lastWarnings.delete(id);
}
return removed;
}
updateUserPosition(position) {
this.currentPosition = position;
this.checkProximity();
}
createComfortZone(center = this.currentPosition, radius) {
const comfortRadius = radius || this.preferences.comfortRadius;
return this.createSafeZone({
center,
radius: comfortRadius,
shape: "sphere",
isActive: true,
visualIndicator: true,
hapticFeedback: true
});
}
createPersonalSpace(center = this.currentPosition) {
return this.createSafeZone({
center,
radius: this.preferences.personalSpace,
shape: "sphere",
isActive: true,
visualIndicator: false,
hapticFeedback: true
});
}
activatePanicMode() {
if (!this.preferences.panicButtonEnabled) return;
const panicZone = this.createSafeZone({
center: this.currentPosition,
radius: 2,
// 2 meter radius
shape: "sphere",
isActive: true,
visualIndicator: true,
hapticFeedback: true
});
this.emit("panic-activated", {
timestamp: Date.now(),
position: this.currentPosition
});
setTimeout(() => {
this.removeSafeZone(panicZone.id);
}, 3e4);
}
startProximityMonitoring() {
this.proximityCheckInterval = window.setInterval(() => {
this.checkProximity();
}, 100);
}
checkProximity() {
for (const zone of this.zones.values()) {
if (!zone.isActive) continue;
const distance = this.calculateDistance(this.currentPosition, zone);
const isInside = distance <= zone.radius;
const isWarningZone = distance <= zone.radius * 1.2;
const wasWarning = this.lastWarnings.has(zone.id);
if (isInside) {
if (!wasWarning) {
const event = {
type: "enter",
zone,
distance,
timestamp: Date.now()
};
this.emit("zone-entered", event);
}
this.lastWarnings.add(zone.id);
} else if (isWarningZone) {
if (!wasWarning) {
const event = {
type: "warning",
zone,
distance,
timestamp: Date.now()
};
this.emit("proximity-warning", event);
}
this.lastWarnings.add(zone.id);
} else {
if (wasWarning) {
const event = {
type: "exit",
zone,
distance,
timestamp: Date.now()
};
this.emit("zone-exited", event);
this.lastWarnings.delete(zone.id);
}
}
}
}
calculateDistance(pos1, zone) {
const dx = pos1.x - zone.center.x;
const dy = pos1.y - zone.center.y;
const dz = pos1.z - zone.center.z;
switch (zone.shape) {
case "sphere":
return Math.sqrt(dx * dx + dy * dy + dz * dz);
case "cylinder":
const radialDistance = Math.sqrt(dx * dx + dz * dz);
const heightDiff = Math.abs(dy);
if (zone.dimensions) {
if (heightDiff > zone.dimensions.y / 2) {
return Math.max(radialDistance, heightDiff - zone.dimensions.y / 2);
}
}
return radialDistance;
case "box":
if (!zone.dimensions) return Math.sqrt(dx * dx + dy * dy + dz * dz);
const halfWidth = zone.dimensions.x / 2;
const halfHeight = zone.dimensions.y / 2;
const halfDepth = zone.dimensions.z / 2;
const distX = Math.max(0, Math.abs(dx) - halfWidth);
const distY = Math.max(0, Math.abs(dy) - halfHeight);
const distZ = Math.max(0, Math.abs(dz) - halfDepth);
return Math.sqrt(distX * distX + distY * distY + distZ * distZ);
default:
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
}
generateZoneId() {
return `zone_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
getZones() {
return Array.from(this.zones.values());
}
getZone(id) {
return this.zones.get(id);
}
updateZone(id, updates) {
const zone = this.zones.get(id);
if (!zone) return false;
Object.assign(zone, updates);
return true;
}
updatePreferences(preferences) {
this.preferences = preferences;
}
getCurrentPosition() {
return { ...this.currentPosition };
}
dispose() {
if (this.proximityCheckInterval) {
clearInterval(this.proximityCheckInterval);
this.proximityCheckInterval = null;
}
this.zones.clear();
this.lastWarnings.clear();
this.removeAllListeners();
}
}
class ProximityDetector extends EventEmitter {
constructor(options = {}) {
super();
this.isActive = false;
this.updateInterval = null;
this.positionHistory = [];
this.velocitySmoothing = { x: 0, y: 0, z: 0 };
this.lastVelocity = { x: 0, y: 0, z: 0 };
this.currentPosition = { x: 0, y: 0, z: 0 };
this.currentControllers = [];
this.options = {
updateInterval: 16,
// ~60fps
warningThreshold: 0.5,
// 50cm warning
criticalThreshold: 0.2,
// 20cm critical
smoothingFactor: 0.8,
predictionTime: 0.5,
// 500ms prediction
...options
};
}
start() {
if (this.isActive) return;
this.isActive = true;
this.updateInterval = window.setInterval(() => {
this.update();
}, this.options.updateInterval);
}
stop() {
if (!this.isActive) return;
this.isActive = false;
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
updatePosition(position, controllers = []) {
const timestamp = Date.now();
this.positionHistory.push({ position: { ...position }, timestamp });
if (this.positionHistory.length > 30) {
this.positionHistory.shift();
}
this.updateVelocity();
this.currentPosition = position;
this.currentControllers = controllers;
}
update() {
}
updateVelocity() {
if (this.positionHistory.length < 2) return;
const latest = this.positionHistory[this.positionHistory.length - 1];
const previous = this.positionHistory[this.positionHistory.length - 2];
if (!latest || !previous) return;
const deltaTime = (latest.timestamp - previous.timestamp) / 1e3;
if (deltaTime <= 0) return;
const instantVelocity = {
x: (latest.position.x - previous.position.x) / deltaTime,
y: (latest.position.y - previous.position.y) / deltaTime,
z: (latest.position.z - previous.position.z) / deltaTime
};
this.lastVelocity = {
x: this.options.smoothingFactor * this.lastVelocity.x + (1 - this.options.smoothingFactor) * instantVelocity.x,
y: this.options.smoothingFactor * this.lastVelocity.y + (1 - this.options.smoothingFactor) * instantVelocity.y,
z: this.options.smoothingFactor * this.lastVelocity.z + (1 - this.options.smoothingFactor) * instantVelocity.z
};
}
checkProximity(zones) {
const events = [];
for (const zone of zones) {
if (!zone.isActive) continue;
const currentDistance = this.calculateDistance(this.currentPosition, zone);
const currentEvent = this.evaluateProximity(zone, currentDistance);
if (currentEvent) {
events.push(currentEvent);
}
const predictedPosition = this.predictFuturePosition(this.options.predictionTime);
const predictedDistance = this.calculateDistance(predictedPosition, zone);
if (predictedDistance < zone.radius && currentDistance > zone.radius) {
const warningEvent = {
type: "warning",
zone,
distance: predictedDistance,
timestamp: Date.now()
};
events.push(warningEvent);
}
for (const controller of this.currentControllers) {
if (!controller.connected) continue;
const controllerDistance = this.calculateDistance(controller.position, zone);
const controllerEvent = this.evaluateProximity(zone, controllerDistance);
if (controllerEvent) {
events.push({
...controllerEvent
// Add controller ID to event data if needed
});
}
}
}
return events;
}
evaluateProximity(zone, distance) {
const timestamp = Date.now();
if (distance <= zone.radius) {
return {
type: "enter",
zone,
distance,
timestamp
};
} else if (distance <= zone.radius + this.options.warningThreshold) {
return {
type: "warning",
zone,
distance,
timestamp
};
}
return null;
}
calculateDistance(position, zone) {
const dx = position.x - zone.center.x;
const dy = position.y - zone.center.y;
const dz = position.z - zone.center.z;
switch (zone.shape) {
case "sphere":
return Math.sqrt(dx * dx + dy * dy + dz * dz);
case "cylinder":
const radialDistance = Math.sqrt(dx * dx + dz * dz);
const heightDiff = Math.abs(dy);
if (zone.dimensions) {
const halfHeight2 = zone.dimensions.y / 2;
if (heightDiff > halfHeight2) {
return Math.sqrt(radialDistance * radialDistance + (heightDiff - halfHeight2) ** 2);
}
}
return radialDistance;
case "box":
if (!zone.dimensions) return Math.sqrt(dx * dx + dy * dy + dz * dz);
const halfWidth = zone.dimensions.x / 2;
const halfHeight = zone.dimensions.y / 2;
const halfDepth = zone.dimensions.z / 2;
const distX = Math.max(0, Math.abs(dx) - halfWidth);
const distY = Math.max(0, Math.abs(dy) - halfHeight);
const distZ = Math.max(0, Math.abs(dz) - halfDepth);
return Math.sqrt(distX * distX + distY * distY + distZ * distZ);
default:
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
}
predictFuturePosition(timeAhead) {
return {
x: this.currentPosition.x + this.lastVelocity.x * timeAhead,
y: this.currentPosition.y + this.lastVelocity.y * timeAhead,
z: this.currentPosition.z + this.lastVelocity.z * timeAhead
};
}
getVelocity() {
return { ...this.lastVelocity };
}
getSpeed() {
const vel = this.lastVelocity;
return Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z);
}
isMoving(threshold = 0.1) {
return this.getSpeed() > threshold;
}
getPositionHistory() {
return [...this.positionHistory];
}
dispose() {
this.stop();
this.positionHistory = [];
this.removeAllListeners();
}
}
const VERSION = "1.1.0";
export { ProximityDetector, SafeZoneManager, VERSION };
//# sourceMappingURL=index.es.js.map