cione-comp-lib
Version:
`$ yarn add cione-comp-lib` 或者 `$ npm i cione-comp-lib -S`
565 lines (564 loc) • 20.7 kB
JavaScript
import { Vector3, Matrix4, PerspectiveCamera, EventDispatcher } from "../../../../_three@0.150.1@three/build/three.module.mjs";
import { XREstimatedLight } from "../../../../_three@0.150.1@three/examples/jsm/webxr/XREstimatedLight.mjs";
import { $currentEnvironmentMap, $currentBackground } from "../features/environment.mjs";
import { $onResize } from "../model-viewer-base.mjs";
import { assertIsArCandidate } from "../utilities.mjs";
import { Damper } from "./Damper.mjs";
import { PlacementBox } from "./PlacementBox.mjs";
import { ChangeSource } from "./SmoothControls.mjs";
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const INIT_FRAMES = 30;
const AR_SHADOW_INTENSITY = 0.8;
const ROTATION_RATE = 1.5;
const HIT_ANGLE_DEG = 20;
const SCALE_SNAP_HIGH = 1.3;
const SCALE_SNAP_LOW = 1 / SCALE_SNAP_HIGH;
const MIN_VIEWPORT_SCALE = 0.25;
const MAX_DISTANCE = 10;
const ARStatus = {
NOT_PRESENTING: "not-presenting",
SESSION_STARTED: "session-started",
OBJECT_PLACED: "object-placed",
FAILED: "failed"
};
const ARTracking = {
TRACKING: "tracking",
NOT_TRACKING: "not-tracking"
};
const vector3 = new Vector3();
const matrix4 = new Matrix4();
const hitPosition = new Vector3();
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
class ARRenderer extends EventDispatcher {
constructor(renderer) {
super();
this.renderer = renderer;
this.currentSession = null;
this.placeOnWall = false;
this.placementBox = null;
this.lastTick = null;
this.turntableRotation = null;
this.oldShadowIntensity = null;
this.frame = null;
this.initialHitSource = null;
this.transientHitTestSource = null;
this.inputSource = null;
this._presentedScene = null;
this.resolveCleanup = null;
this.exitWebXRButtonContainer = null;
this.overlay = null;
this.xrLight = null;
this.tracking = true;
this.frames = 0;
this.initialized = false;
this.oldTarget = new Vector3();
this.placementComplete = false;
this.isTranslating = false;
this.isRotating = false;
this.isTwoFingering = false;
this.lastDragPosition = new Vector3();
this.firstRatio = 0;
this.lastAngle = 0;
this.goalPosition = new Vector3();
this.goalYaw = 0;
this.goalScale = 1;
this.xDamper = new Damper();
this.yDamper = new Damper();
this.zDamper = new Damper();
this.yawDamper = new Damper();
this.scaleDamper = new Damper();
this.onExitWebXRButtonContainerClick = () => this.stopPresenting();
this.onUpdateScene = () => {
if (this.placementBox != null && this.isPresenting) {
this.placementBox.dispose();
this.placementBox = new PlacementBox(this.presentedScene, this.placeOnWall ? "back" : "bottom");
}
};
this.onSelectStart = (event) => {
const hitSource = this.transientHitTestSource;
if (hitSource == null) {
return;
}
const fingers = this.frame.getHitTestResultsForTransientInput(hitSource);
const scene = this.presentedScene;
const box = this.placementBox;
if (fingers.length === 1) {
this.inputSource = event.inputSource;
const { axes } = this.inputSource.gamepad;
const hitPosition2 = box.getHit(this.presentedScene, axes[0], axes[1]);
box.show = true;
if (hitPosition2 != null) {
this.isTranslating = true;
this.lastDragPosition.copy(hitPosition2);
} else if (this.placeOnWall === false) {
this.isRotating = true;
this.lastAngle = axes[0] * ROTATION_RATE;
}
} else if (fingers.length === 2) {
box.show = true;
this.isTwoFingering = true;
const { separation } = this.fingerPolar(fingers);
this.firstRatio = separation / scene.scale.x;
}
};
this.onSelectEnd = () => {
this.isTranslating = false;
this.isRotating = false;
this.isTwoFingering = false;
this.inputSource = null;
this.goalPosition.y += this.placementBox.offsetHeight * this.presentedScene.scale.x;
this.placementBox.show = false;
};
this.threeRenderer = renderer.threeRenderer;
this.threeRenderer.xr.enabled = true;
}
async resolveARSession() {
assertIsArCandidate();
const session = await navigator.xr.requestSession("immersive-ar", {
requiredFeatures: ["hit-test"],
optionalFeatures: ["dom-overlay", "light-estimation"],
domOverlay: this.overlay ? { root: this.overlay } : void 0
});
this.threeRenderer.xr.setReferenceSpaceType("local");
await this.threeRenderer.xr.setSession(session);
this.threeRenderer.xr.cameraAutoUpdate = false;
return session;
}
get presentedScene() {
return this._presentedScene;
}
async supportsPresentation() {
try {
assertIsArCandidate();
return await navigator.xr.isSessionSupported("immersive-ar");
} catch (error) {
console.warn("Request to present in WebXR denied:");
console.warn(error);
console.warn("Falling back to next ar-mode");
return false;
}
}
async present(scene, environmentEstimation = false) {
if (this.isPresenting) {
console.warn("Cannot present while a model is already presenting");
}
let waitForAnimationFrame = new Promise((resolve, _reject) => {
requestAnimationFrame(() => resolve());
});
scene.setHotspotsVisibility(false);
scene.queueRender();
await waitForAnimationFrame;
this._presentedScene = scene;
this.overlay = scene.element.shadowRoot.querySelector("div.default");
if (environmentEstimation === true) {
this.xrLight = new XREstimatedLight(this.threeRenderer);
this.xrLight.addEventListener("estimationstart", () => {
if (!this.isPresenting || this.xrLight == null) {
return;
}
const scene2 = this.presentedScene;
scene2.add(this.xrLight);
scene2.environment = this.xrLight.environment;
});
}
const currentSession = await this.resolveARSession();
currentSession.addEventListener("end", () => {
this.postSessionCleanup();
}, { once: true });
const exitButton = scene.element.shadowRoot.querySelector(".slot.exit-webxr-ar-button");
exitButton.classList.add("enabled");
exitButton.addEventListener("click", this.onExitWebXRButtonContainerClick);
this.exitWebXRButtonContainer = exitButton;
const viewerRefSpace = await currentSession.requestReferenceSpace("viewer");
this.tracking = true;
this.frames = 0;
this.initialized = false;
this.turntableRotation = scene.yaw;
this.goalYaw = scene.yaw;
this.goalScale = 1;
scene.background = null;
this.oldShadowIntensity = scene.shadowIntensity;
scene.setShadowIntensity(0.01);
this.oldTarget.copy(scene.getTarget());
scene.element.addEventListener("load", this.onUpdateScene);
const radians = HIT_ANGLE_DEG * Math.PI / 180;
const ray = this.placeOnWall === true ? void 0 : new XRRay(new DOMPoint(0, 0, 0), { x: 0, y: -Math.sin(radians), z: -Math.cos(radians) });
currentSession.requestHitTestSource({ space: viewerRefSpace, offsetRay: ray }).then((hitTestSource) => {
this.initialHitSource = hitTestSource;
});
this.currentSession = currentSession;
this.placementBox = new PlacementBox(scene, this.placeOnWall ? "back" : "bottom");
this.placementComplete = false;
this.lastTick = performance.now();
this.dispatchEvent({ type: "status", status: ARStatus.SESSION_STARTED });
}
async stopPresenting() {
if (!this.isPresenting) {
return;
}
const cleanupPromise = new Promise((resolve) => {
this.resolveCleanup = resolve;
});
try {
await this.currentSession.end();
await cleanupPromise;
} catch (error) {
console.warn("Error while trying to end WebXR AR session");
console.warn(error);
this.postSessionCleanup();
}
}
get isPresenting() {
return this.presentedScene != null;
}
get target() {
return this.oldTarget;
}
updateTarget() {
const scene = this.presentedScene;
if (scene != null) {
const target = scene.getTarget();
this.oldTarget.copy(target);
if (this.placeOnWall) {
target.z = scene.boundingBox.min.z;
} else {
target.y = scene.boundingBox.min.y;
}
scene.setTarget(target.x, target.y, target.z);
}
}
postSessionCleanup() {
const session = this.currentSession;
if (session != null) {
session.removeEventListener("selectstart", this.onSelectStart);
session.removeEventListener("selectend", this.onSelectEnd);
this.currentSession = null;
}
const scene = this.presentedScene;
this._presentedScene = null;
if (scene != null) {
const { element } = scene;
if (this.xrLight != null) {
scene.remove(this.xrLight);
this.xrLight.dispose();
this.xrLight = null;
}
scene.position.set(0, 0, 0);
scene.scale.set(1, 1, 1);
scene.setShadowOffset(0);
const yaw = this.turntableRotation;
if (yaw != null) {
scene.yaw = yaw;
}
const intensity = this.oldShadowIntensity;
if (intensity != null) {
scene.setShadowIntensity(intensity);
}
scene.setEnvironmentAndSkybox(element[$currentEnvironmentMap], element[$currentBackground]);
const point = this.oldTarget;
scene.setTarget(point.x, point.y, point.z);
scene.xrCamera = null;
scene.element.removeEventListener("load", this.onUpdateScene);
scene.orientHotspots(0);
element.requestUpdate("cameraTarget");
element.requestUpdate("maxCameraOrbit");
element[$onResize](element.getBoundingClientRect());
requestAnimationFrame(() => {
scene.element.dispatchEvent(new CustomEvent("camera-change", { detail: { source: ChangeSource.NONE } }));
});
}
this.renderer.height = 0;
const exitButton = this.exitWebXRButtonContainer;
if (exitButton != null) {
exitButton.classList.remove("enabled");
exitButton.removeEventListener("click", this.onExitWebXRButtonContainerClick);
this.exitWebXRButtonContainer = null;
}
const hitSource = this.transientHitTestSource;
if (hitSource != null) {
hitSource.cancel();
this.transientHitTestSource = null;
}
const hitSourceInitial = this.initialHitSource;
if (hitSourceInitial != null) {
hitSourceInitial.cancel();
this.initialHitSource = null;
}
if (this.placementBox != null) {
this.placementBox.dispose();
this.placementBox = null;
}
this.lastTick = null;
this.turntableRotation = null;
this.oldShadowIntensity = null;
this.frame = null;
this.inputSource = null;
this.overlay = null;
if (this.resolveCleanup != null) {
this.resolveCleanup();
}
this.dispatchEvent({ type: "status", status: ARStatus.NOT_PRESENTING });
}
updateView(view) {
const scene = this.presentedScene;
const xr = this.threeRenderer.xr;
xr.updateCamera(camera);
scene.xrCamera = xr.getCamera();
const { elements } = scene.getCamera().matrixWorld;
scene.orientHotspots(Math.atan2(elements[1], elements[5]));
if (!this.initialized) {
this.placeInitially();
this.initialized = true;
}
if (view.requestViewportScale && view.recommendedViewportScale) {
const scale = view.recommendedViewportScale;
view.requestViewportScale(Math.max(scale, MIN_VIEWPORT_SCALE));
}
const layer = xr.getBaseLayer();
if (layer != null) {
const viewport = layer instanceof XRWebGLLayer ? layer.getViewport(view) : xr.getBinding().getViewSubImage(layer, view).viewport;
this.threeRenderer.setViewport(viewport.x, viewport.y, viewport.width, viewport.height);
}
}
placeInitially() {
const scene = this.presentedScene;
const { position, element } = scene;
const xrCamera = scene.getCamera();
const { width, height } = this.overlay.getBoundingClientRect();
scene.setSize(width, height);
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
const { theta, radius } = element.getCameraOrbit();
const cameraDirection = xrCamera.getWorldDirection(vector3);
scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta;
this.goalYaw = scene.yaw;
position.copy(xrCamera.position).add(cameraDirection.multiplyScalar(radius));
this.updateTarget();
const target = scene.getTarget();
position.add(target).sub(this.oldTarget);
this.goalPosition.copy(position);
scene.setHotspotsVisibility(true);
const { session } = this.frame;
session.addEventListener("selectstart", this.onSelectStart);
session.addEventListener("selectend", this.onSelectEnd);
session.requestHitTestSourceForTransientInput({ profile: "generic-touchscreen" }).then((hitTestSource) => {
this.transientHitTestSource = hitTestSource;
});
}
getTouchLocation() {
const { axes } = this.inputSource.gamepad;
let location = this.placementBox.getExpandedHit(this.presentedScene, axes[0], axes[1]);
if (location != null) {
vector3.copy(location).sub(this.presentedScene.getCamera().position);
if (vector3.length() > MAX_DISTANCE)
return null;
}
return location;
}
getHitPoint(hitResult) {
const refSpace = this.threeRenderer.xr.getReferenceSpace();
const pose = hitResult.getPose(refSpace);
if (pose == null) {
return null;
}
const hitMatrix = matrix4.fromArray(pose.transform.matrix);
if (this.placeOnWall === true) {
this.goalYaw = Math.atan2(hitMatrix.elements[4], hitMatrix.elements[6]);
}
return hitMatrix.elements[5] > 0.75 !== this.placeOnWall ? hitPosition.setFromMatrixPosition(hitMatrix) : null;
}
moveToFloor(frame) {
const hitSource = this.initialHitSource;
if (hitSource == null) {
return;
}
const hitTestResults = frame.getHitTestResults(hitSource);
if (hitTestResults.length == 0) {
return;
}
const hit = hitTestResults[0];
const hitPoint = this.getHitPoint(hit);
if (hitPoint == null) {
return;
}
this.placementBox.show = true;
if (!this.isTranslating) {
if (this.placeOnWall) {
this.goalPosition.copy(hitPoint);
} else {
this.goalPosition.y = hitPoint.y;
}
}
hitSource.cancel();
this.initialHitSource = null;
this.dispatchEvent({ type: "status", status: ARStatus.OBJECT_PLACED });
}
fingerPolar(fingers) {
const fingerOne = fingers[0].inputSource.gamepad.axes;
const fingerTwo = fingers[1].inputSource.gamepad.axes;
const deltaX = fingerTwo[0] - fingerOne[0];
const deltaY = fingerTwo[1] - fingerOne[1];
const angle = Math.atan2(deltaY, deltaX);
let deltaYaw = this.lastAngle - angle;
if (deltaYaw > Math.PI) {
deltaYaw -= 2 * Math.PI;
} else if (deltaYaw < -Math.PI) {
deltaYaw += 2 * Math.PI;
}
this.lastAngle = angle;
return {
separation: Math.sqrt(deltaX * deltaX + deltaY * deltaY),
deltaYaw
};
}
processInput(frame) {
const hitSource = this.transientHitTestSource;
if (hitSource == null) {
return;
}
if (!this.isTranslating && !this.isTwoFingering && !this.isRotating) {
return;
}
const fingers = frame.getHitTestResultsForTransientInput(hitSource);
const scene = this.presentedScene;
const scale = scene.scale.x;
if (this.isTwoFingering) {
if (fingers.length < 2) {
this.isTwoFingering = false;
} else {
const { separation, deltaYaw } = this.fingerPolar(fingers);
if (this.placeOnWall === false) {
this.goalYaw += deltaYaw;
}
if (scene.canScale) {
const scale2 = separation / this.firstRatio;
this.goalScale = scale2 < SCALE_SNAP_HIGH && scale2 > SCALE_SNAP_LOW ? 1 : scale2;
}
}
return;
} else if (fingers.length === 2) {
this.isTranslating = false;
this.isRotating = false;
this.isTwoFingering = true;
const { separation } = this.fingerPolar(fingers);
this.firstRatio = separation / scale;
return;
}
if (this.isRotating) {
const angle = this.inputSource.gamepad.axes[0] * ROTATION_RATE;
this.goalYaw += angle - this.lastAngle;
this.lastAngle = angle;
} else if (this.isTranslating) {
fingers.forEach((finger) => {
if (finger.inputSource !== this.inputSource) {
return;
}
let hit = null;
if (finger.results.length > 0) {
hit = this.getHitPoint(finger.results[0]);
}
if (hit == null) {
hit = this.getTouchLocation();
}
if (hit == null) {
return;
}
this.goalPosition.sub(this.lastDragPosition);
if (this.placeOnWall === false) {
const offset = hit.y - this.lastDragPosition.y;
if (offset < 0) {
this.placementBox.offsetHeight = offset / scale;
this.presentedScene.setShadowOffset(offset);
const cameraPosition = vector3.copy(scene.getCamera().position);
const alpha = -offset / (cameraPosition.y - hit.y);
cameraPosition.multiplyScalar(alpha);
hit.multiplyScalar(1 - alpha).add(cameraPosition);
}
}
this.goalPosition.add(hit);
this.lastDragPosition.copy(hit);
});
}
}
moveScene(delta) {
const scene = this.presentedScene;
const { position, yaw } = scene;
const boundingRadius = scene.boundingSphere.radius;
const goal = this.goalPosition;
const oldScale = scene.scale.x;
const box = this.placementBox;
let source = ChangeSource.NONE;
if (!goal.equals(position) || this.goalScale !== oldScale) {
source = ChangeSource.USER_INTERACTION;
let { x, y, z } = position;
x = this.xDamper.update(x, goal.x, delta, boundingRadius);
y = this.yDamper.update(y, goal.y, delta, boundingRadius);
z = this.zDamper.update(z, goal.z, delta, boundingRadius);
position.set(x, y, z);
const newScale = this.scaleDamper.update(oldScale, this.goalScale, delta, 1);
scene.scale.set(newScale, newScale, newScale);
if (!this.isTranslating) {
const offset = goal.y - y;
if (this.placementComplete && this.placeOnWall === false) {
box.offsetHeight = offset / newScale;
scene.setShadowOffset(offset);
} else if (offset === 0) {
this.placementComplete = true;
box.show = false;
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
}
}
}
box.updateOpacity(delta);
scene.updateTarget(delta);
scene.yaw = this.yawDamper.update(yaw, this.goalYaw, delta, Math.PI);
scene.element.dispatchEvent(new CustomEvent("camera-change", { detail: { source } }));
}
onWebXRFrame(time, frame) {
this.frame = frame;
++this.frames;
const refSpace = this.threeRenderer.xr.getReferenceSpace();
const pose = frame.getViewerPose(refSpace);
if (pose == null && this.tracking === true && this.frames > INIT_FRAMES) {
this.tracking = false;
this.dispatchEvent({ type: "tracking", status: ARTracking.NOT_TRACKING });
}
const scene = this.presentedScene;
if (pose == null || scene == null || !scene.element.loaded) {
this.threeRenderer.clear();
return;
}
if (this.tracking === false) {
this.tracking = true;
this.dispatchEvent({ type: "tracking", status: ARTracking.TRACKING });
}
let isFirstView = true;
for (const view of pose.views) {
this.updateView(view);
if (isFirstView) {
this.moveToFloor(frame);
this.processInput(frame);
const delta = time - this.lastTick;
this.moveScene(delta);
this.renderer.preRender(scene, time, delta);
this.lastTick = time;
scene.renderShadow(this.threeRenderer);
}
this.threeRenderer.render(scene, scene.getCamera());
isFirstView = false;
}
}
}
export { ARRenderer, ARStatus, ARTracking };