VR模組開發教學
[info] 小提示:
依賴套件
本教學的範例站台使用了以下的套件:
sweetalert2: 用來顯示提示訊息的套件,詳見官方網站。
cuon-matrix.js: 用來處理矩陣運算的WebGL套件,詳見來源。
請參考官方網站或教學中 index.html 的引入方式。
初始化VR
VR初始化須依賴核心生成的div
,所以需要寫在地形開啟後的CallBack
中。
[info] 特別提醒:
若是系統沒有接上VR設備(或是沒有安裝模擬器),在初始化VR時會將按鈕狀態設為disabled無法點擊進入。
index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<!-- CSS -->
<link href="./css/PGWeb3D.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="./css/styles.css">
<link rel="stylesheet" href="./css/sweetalert2.css">
<!-- JS -->
<script src="./js/sweetalert2.all.js"></script>
<script src="./js/PGWeb3D.min.js"></script>
<script src="./js/cuon-matrix.js"></script>
<script src="./js/CameraQuatenion.js"></script>
<script src="./js/menuHandle.js"></script>
</head>
<body>
<div id="bodyContainer">
<div id="ToolContainer">
<div id="userName" onclick="setUserName()">設定您的名稱</div>
<button id="vr" disabled>VR Unavailable</button>
<div class="btnFunc">
<span id="btnCreateRoom" onclick="createRoom()">建立會議</span>
</div>
<button id="btnJoinRoom" class="btnFunc" onclick="joinRoom()">加入會議</button>
</div>
<div id="MapContainer">
</div>
</div>
</body>
<script src="./js/main.js"></script>
</html>
main.js
:
管理整個 XR 多人連線會議室,包含VR按鈕的初始化、連線的建立、加入、離開等功能。
// main_improved.js
// 引入必要的庫和工具
const Swal = window.Swal;
const ov = window.ov;
const GeoPoint = window.GeoPoint;
const Geo3DPoint = window.Geo3DPoint;
const GeoPolyline = window.GeoPolyline;
// 初始化圖台
var terrainview = new ov.TerrainView("MapContainer");
// 設定全域變數 terrainview,方便之後在瀏覽器的 Console 來 Debug
window.terrainview = terrainview;
// Connection Info
const hostUrl = window.location.origin + window.location.pathname; // 網站的 URL
// 設定伺服器的 WebSocket URL
const socketServerUrl = "vroom.colife.org.tw";
const socketServerPort = "30076";
// 圖層
var pointerLayer; // 圖標圖層
// 變數
var userName; // 預設使用者名稱
var userDevice; // 使用者裝置
var createSymbolStatus = false; // 圖標功能狀態
var meetRoom = null; // 會議室
var guests = new Map(); // 訪客列表
var cameraUpdateInterval = 41.6; // 相機更新間隔, 41.6ms 約 24fps
var cameraUpdateIntervalHandle = null; // 相機更新間隔的 handle
var isVRMode = false; // 是否為 VR 模式
const EARTH_RADIUS = 6371008.8; // 地球半徑(公尺)
const buttonVR = document.getElementById("vr");
// 開源底圖資源集合
const baseLayerResource = {
bing_i: { url: "BING_MAP", identifier: "IMAGE" },
bing_v: { url: "BING_MAP", identifier: "VECTOR" },
bing_vi: { url: "BING_MAP", identifier: "VECTOR_IMAGE" },
nlsc_e: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "EMAP" },
nlsc_eg: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "EMAP01" },
nlsc_p: { url: "https://wmts.nlsc.gov.tw/wmts", identifier: "PHOTO2" },
osm: { url: "OSM", identifier: "" },
};
terrainview.openTerrain({
url: "https://data-3dgdp.colife.org.tw/oviewRP.ashx",
identifier: "terrain",
callback: openTerrainCallback,
});
// 地形開啟後的 callback
function openTerrainCallback(terrain, success) {
// 設定底圖
let result = terrainview.setBaseLayer(baseLayerResource.nlsc_e);
// 關閉光源
terrainview.enableLight = false;
// 關閉面板
terrainview.controlPanel = false;
// terrainview event
terrainview.addEventListener("MouseDown", handleMouseDown);
// mobile touch event
document.addEventListener("touchend", handleTouchEnd);
// Layers
pointerLayer = terrainview.addCustomLayer("pointerLayer");
// 設定初始相機位置
initCamera();
// websocket
connectWebSocket();
// VR
watchVRButton();
document.addEventListener("keyup", handleKeyUp);
// 初始化VR,需在https或localhost下,
// 如果系統支援VR,按鈕會變的可按。
// @param {HTMLButtonElement} buttonElement 按鈕元件。
// @param {function} callback VR啟動後的回呼函式。
terrainview.initVR(buttonVR, initVRSuccess);
}
// 檢查會議室狀態
async function meetRoomCheck() {
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get("room");
if (roomId && roomId.length === 32) {
console.log("meetRoomCheck-roomId:", roomId);
while (!userName) {
await setUserName();
if (!userName) { saAlert("請先設定您的使用者名稱"); }
}
joinRoom(roomId);
}
}
// 檢查裝置類型
function deviceCheck() {
let userDevice = "";
if (isVRMode) {
userDevice = "vr";
} else if (navigator.userAgent.match(/(iPhone|iPod|iPad|Android|ios|SymbianOS)/i)) {
userDevice = "mobile";
terrainview.controlPanel = true;
} else {
userDevice = "pc";
}
return userDevice;
}
// 監聽 VR 按鈕
function watchVRButton() {
let button = document.querySelector('button#vr');
let observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.attributeName === 'disabled') {
let isDisabled = button.getAttribute('disabled');
if (!isDisabled) {
console.log('Button has become enabled');
button.innerText = "啟用VR模式";
}
}
});
});
let config = { attributes: true };
observer.observe(button, config);
}
// 初始化相機位置
function initCamera(fly = false) {
let initialPos = new GeoPoint(120.2987197002847, 22.664748230709762, 25);
let initialV = new Geo3DPoint(0, 0, -1);
let initialUp = new Geo3DPoint(0, 1, 0);
let initialCamera = new ov.Camera(initialPos, initialV, initialUp);
terrainview.gotoCamera(initialCamera, fly);
}
/**
* 建立圖標
* @param {Object} pos 圖標位置
* @param {string} name 圖標名稱
* @param {boolean} isSelf 是否為自己建立的圖標
*/
function createSymbol(pos, name, isSelf = true) {
customSymbol = pointerLayer.addPointEntity({
geo: new GeoPoint(pos.x, pos.y, 10),
color: new ov.Color([255, 0, 0]),
absHeight: false,
symbolFixOnXYPlane: true,
size: 16,
label: {
text: `[${isSelf ? userName : name}]\n${getTimestampTag()}`,
color: new ov.Color([200, 50, 50]),
borderColor: new ov.Color([255, 255, 255]),
size: 16,
},
});
customSymbol.setTooltip(`經度: ${pos.y}<br>緯度: ${pos.x}`);
if (isSelf) {
let message = { type: "createSymbol", name: userName, pos: pos };
sendWebSocketMessage(JSON.stringify(message));
}
}
// 取得時間戳記
function getTimestampTag() {
return (
"<" +
new Date()
.toLocaleString("tw", { hour12: false })
.replaceAll("/", "")
.replace(" ", "-")
.substr(9) +
">"
);
}
// 設定建立圖標功能為開啟
function createSymbolHandler() {
createSymbolStatus = true;
}
// 開始傳送相機資訊
function startSendMyCameraHandler() {
sendMyCamera();
sendMyUserDeviceType();
cameraUpdateIntervalHandle = setInterval(sendMyCamera, cameraUpdateInterval);
}
// 停止傳送相機資訊
function stopSendMyCameraHandler() {
clearInterval(cameraUpdateIntervalHandle);
}
// 傳送相機資訊
function sendMyCamera() {
let cameraInfo = getCamaeraInfo();
let message = {
type: "updateCamera",
payload: { user: userName, id: meetRoom.id, camera: cameraInfo },
};
if (isVRMode) {
const inputs = terrainview.getXRViewInformation().inputs;
if (inputs && inputs.length > 0) message.payload.controller = terrainview.getXRViewInformation().inputs;
}
sendWebSocketMessage(JSON.stringify(message));
}
// 傳送使用者裝置類型
function sendMyUserDeviceType() {
userDevice = deviceCheck();
console.log('sendMyUserDeviceType:', userDevice);
let message = {
type: "setUserDeviceType",
payload: { user: userName, id: meetRoom.id, userDevice: userDevice },
};
sendWebSocketMessage(JSON.stringify(message));
}
// 取得相機資訊
function getCamaeraInfo() {
let { pos, v, up } = terrainview.camera;
let cameraInfo = {
position: [pos.x, pos.y, pos.z],
v: [v.x, v.y, v.z],
up: [up.x, up.y, up.z],
};
return cameraInfo;
}
/** WebSocket */
let ws;
// 連線 WebSocket
function connectWebSocket() {
ws = new WebSocket(`wss://${socketServerUrl}:${socketServerPort}`);
// WebSocket 連線成功事件
ws.onopen = function (event) {
console.log("WebSocket is open now.");
meetRoomCheck();
};
// WebSocket 收到訊息事件
ws.onmessage = handleWebSocketMessage;
// WebSocket 連線關閉事件
ws.onclose = function (event) {
console.log("WebSocket is closed now.");
saAlert("通訊伺服器連線已斷開", "error");
stopSendMyCameraHandler();
};
// WebSocket 錯誤事件
ws.onerror = function (event) {
console.log("WebSocket error observed:", event);
};
}
// 處理 WebSocket 收到的訊息
function handleWebSocketMessage(message) {
if (isJSON(message.data?.toString())) {
let data = JSON.parse(message.data.toString());
switch (data.type) {
case "createSymbol":
createSymbol(data.pos, data.name, false);
break;
case "chat":
console.log(data.from + ": " + data.payload);
break;
case "createRoom":
!meetRoom && createRoomHandler(data.payload);
break;
case "joinRoom":
joinRoomHandler(data.payload);
break;
case "sendMessage":
break;
case "command":
runCommand(data.payload);
break;
default:
console.log("unknown type");
break;
}
}
}
// 送出 WebSocket 訊息
function sendWebSocketMessage(message) {
ws.send(message);
}
// 創建房間
async function createRoom() {
if (!userName) { saAlert("請先設定您的使用者名稱"); return; }
let roomName = (await saPrompt("請輸入您想使用的房間名稱", "測試會議室")).value ?? "";
if (roomName.length < 3 || roomName.length > 36) {
saAlert("房間名稱長度最少需要 3 個字元,最多 36 個字元", "warning")
return;
}
if (roomName) {
roomName = roomName.replace(/</g, "<").replace(/>/g, ">");
}
sendWebSocketMessage(JSON.stringify({ type: "createRoom", payload: { roomName } }));
}
// 加入房間
async function joinRoom(id) {
const roomId = id ?? (await saPrompt("請輸入房間代號", ""))?.value;
if (!roomId || !roomId.match(/^[a-zA-Z0-9]{32}$/)) {
saAlert("此房間代號不存在。", "warning");
return;
}
let message = { type: "joinRoom", payload: { roomId } };
sendWebSocketMessage(JSON.stringify(message));
}
// 處理加入房間的回應
function joinRoomHandler(data) {
const { result } = data;
if (result == 'success') {
const { roomName, roomId } = data;
meetRoom = new Room(roomName, roomId);
console.log(`Join room success: ${roomName}(${roomId})`);
saAlert(`您已成功加入 ${roomName}(${roomId})`, "success");
document.getElementById("btnJoinRoom").hidden = true;
document.getElementById("btnCreateRoom").innerText = "複製房間連結";
document.getElementById("btnCreateRoom").onclick = copyRoomLink;
startSendMyCameraHandler();
} else {
saAlert("加入房間失敗。", "warning");
}
}
// 設定使用者名稱
async function setUserName() {
const promptResult = (await saPrompt("請輸入你想顯示的使用者名稱", getRandomUserName())).value ?? "";
if (!promptResult) { return }
else if (promptResult.length < 3 || promptResult.length > 24) { saAlert("名稱長度最少需要 3 個字元,最多 24 個字元", "warning"); return }
userName = promptResult;
const nameEle = document.getElementById("userName")
nameEle.display = "block";
nameEle.innerHTML = `Hi ${userName}.`;
nameEle.classList.add("named");
ws.send(JSON.stringify({ type: "setUserName", payload: { name: userName } }));
return userName;
}
// 執行指令
function runCommand(command) {
switch (command.cmd) {
case "updateCamera":
updateGuestCamera(command);
break;
case "setUserDeviceType":
updateGuestDeviceType(command);
break;
case "createRoom":
createRoomHandler(command.payload);
break;
case "guestLeaveRoom":
guestLeaveRoomHandler(command.payload);
break;
default:
console.log('command:', command)
console.log("unknown command");
break;
}
}
// 更新訪客相機
function updateGuestCamera(command) {
let { user, camera, controller } = command;
if (meetRoom && !meetRoom.guests.has(user)) {
createGuset(user, camera);
}
let guest = meetRoom.guests.get(user);
guest.updateCamera(camera);
if (controller) {
guest.updateController(controller);
}
}
// 更新訪客裝置類型
function updateGuestDeviceType(command) {
let { user, userDevice } = command;
if (meetRoom && !meetRoom.guests.has(user)) {
return;
}
let guest = meetRoom.guests.get(user);
console.log('updateGuestDeviceType:', user, userDevice);
guest.updateDeviceType(userDevice);
}
// 創建房間的處理
function createRoomHandler(data) {
const { result } = data;
if (result) {
const { roomName, roomId } = data;
meetRoom = new Room(roomName, roomId);
navigator.clipboard.writeText(meetRoom.roomUrl);
console.log(`Create room success: ${roomName}(${roomId})`);
saAlert(`房間建立成功: ${roomName}`, "success", `已複製房間連結`);
document.getElementById("btnJoinRoom").hidden = true;
document.getElementById("btnCreateRoom").innerText = "複製房間連結";
document.getElementById("btnCreateRoom").onclick = copyRoomLink;
startSendMyCameraHandler();
} else {
saAlert("房間建立失敗。", "warning");
}
}
// 處理訪客離開房間的回應
function guestLeaveRoomHandler(data) {
const { userName } = data;
if (userName && meetRoom && meetRoom.guests.has(userName)) {
meetRoom.removeGuest(userName);
Toast.fire({ icon: "warning", title: `${userName} 離開房間` });
}
}
// 創建訪客
function createGuset(name, camera, deviceType) {
let guest = new Guest(name, camera, deviceType);
meetRoom.addGuest(guest);
Toast.fire({ icon: "success", title: `${name} 加入房間` });
}
// 複製房間連結
function copyRoomLink() {
if (!meetRoom) {
saAlert("請先加入或建立房間", "warning");
return;
}
const roomUrl = meetRoom.roomUrl;
navigator.clipboard.writeText(roomUrl);
saAlert(`房間的加入連結已複製: ${roomUrl}`, "success");
}
class Guest {
constructor(name, camera, deviceType) {
this.name = name;
this.camera = camera;
this.cameraModel = null;
this.minCameraScale = 0.2;
this.maxCameraScale = 32.2;
this.maxCameraScaleDistance = 5000;
this.baseSize = 10064.412132311258;
this.modelLayer = null;
this.deviceType = deviceType || "pc";
this.deviceIcon = {
"vr": "🥽",
"mobile": "📱",
"pc": "💻",
}
this.controllerLeft = null;
this.controllerRight = null;
this.initModel();
}
initModel() {
console.log("initModel");
this.createCustomLayer();
console.log("createCustomLayer");
this.createModel();
console.log("createModel");
}
createCustomLayer() {
this.modelLayer = terrainview.addCustomLayer(
this.name + Math.trunc(Math.random() * 1000)
);
}
createModel() {
if (this.modelLayer) {
let { position: pos } = this.camera;
console.log(`pos`, pos);
this.cameraModel = this.modelLayer.addGLTFEntity({
src: `${hostUrl}src/model/VRglass_F_X.glb`,
position: new GeoPoint(...pos),
minRange: 5,
labelOffset: new GeoPoint(0, 3),
scale: 0.2,
label: {
text: `${this.deviceIcon[this.deviceType]} ${this.name}`,
worldSize: true,
size: 0.8,
color: new ov.Color("blue"),
fontSize: 120,
font: "arial",
borderColor: new ov.Color("white"),
borderSize: 12,
},
});
if (this.name === "November-287") {
window.g = this;
}
}
}
createController(controllerInfo) {
if (this.modelLayer) {
let { handedness: hand, worldPosition: pos, worldUp: up, worldView: v } = controllerInfo;
console.log(`try to createController(${hand})`);
console.log(`controller:`, controllerInfo)
if (hand === "left") { this.controllerLeft = {}; }
if (hand === "right") { this.controllerRight = {}; }
const currentController = hand === "left" ? this.controllerLeft : this.controllerRight;
currentController.model = this.modelLayer.addGLTFEntity({
src: `${hostUrl}src/model/vr_handle_from_blob.glb`,
position: new GeoPoint(pos),
scale: 0.2,
minRange: 5,
});
currentController.lineLength = 50;
currentController.lineWidth = 2;
currentController.centerPoint = new GeoPoint(pos);
currentController.TargetPoint = getTranslatedPointByVector(currentController.centerPoint, v, currentController.lineLength);
currentController.lineColor = hand == 'left' ? new ov.Color('00AA00') : new ov.Color('FF4444');
currentController.line = this.modelLayer.addPolylineEntity({
geo: new GeoPolyline([currentController.centerPoint, currentController.TargetPoint]),
color: currentController.lineColor,
size: currentController.lineWidth
})
console.log(`createController(${hand}) success`);
console.log(`currentController:`, currentController.model.getParameter().src);
}
}
// 計算當前 Guest 相機模型的顯示尺寸 (pixel)
getCameraScreenSize() {
return this.cameraModel._entity._CalcScreenSize(terrainview._TerrainEngine.MainCameraFrustum, terrainview._TerrainEngine.Device);
}
// 計算當前相機與 Guest 的距離
getDistance() {
return calcPointGeoDistance(terrainview.camera.pos, this.cameraModel._entity._OriginalGeo);
}
// 根據當前與Guest的距離、Guest的相機模型實際顯示的 pixel 尺寸及 Guest 的相機模型的 scale 計算出新的基礎尺寸
setBaseSizeAsCurrentView() {
this.baseSize = (this.getCameraScreenSize * this.getDistance) / this.getScale();
return this.baseSize;
}
setControllerModel(model) {
this.controllerLeft.model.update({ src: `${hostUrl}src/model/${model}` });
}
getScale() {
return this.cameraModel._entity._Scale;
}
setScale(scale) {
this.cameraModel.update({ scale });
}
calcScale() {
let distance = this.getDistance();
let size = 65;
let scale = (size * distance) / this.baseSize;
if (scale < this.minCameraScale) scale = this.minCameraScale;
if (scale > this.maxCameraScale) scale = this.maxCameraScale;
return scale;
}
updateCamera(camera) {
if (this.cameraModel) {
this.camera = camera;
let { position: pos, v, up } = camera;
let scale = this.calcScale();
this.cameraModel.update({
position: new GeoPoint(...pos),
quaternionRotate: convertToQuaternion(
{ x: v[0], y: v[1], z: v[2] },
{ x: up[0], y: up[1], z: up[2] },
true
),
scale: scale,
labelOffset: new GeoPoint(0, Math.max(2, (scale - 0.2) * 3)),
label: {
size: 0.8 + Math.max(0, (scale - 0.2) * 3),
},
});
}
}
updateController(controller) {
if (controller) {
controller.forEach((c) => {
let { handedness: hand, worldPosition: pos, worldUp: up, worldView: v } = c;
window.hand = { up, v };
if (hand === "left" && !this.controllerLeft) { this.createController(c); }
else if (hand === "right" && !this.controllerRight) { this.createController(c); }
else {
const currentController = c.handedness === "left" ? this.controllerLeft : this.controllerRight;
// 更新手把姿態
currentController.model.update({
position: new GeoPoint(pos),
quaternionRotate: convertToQuaternion(
{ x: v.x, y: v.y, z: v.z },
{ x: up.x, y: up.y, z: up.z }
),
});
// 更新手把射線
currentController.centerPoint = new GeoPoint(pos);
currentController.TargetPoint = getTranslatedPointByVector(currentController.centerPoint, v, currentController.lineLength);
currentController.line.update({ geo: new GeoPolyline([currentController.centerPoint, currentController.TargetPoint]) })
// 數據記錄
currentController.up = up;
currentController.v = v;
}
});
}
}
updateDeviceType(type) {
if (type) {
this.deviceType = type;
this.cameraModel.update({ label: { text: `${this.deviceIcon[this.deviceType]} ${this.name}` } });
}
}
deleteModel() {
console.log(`Layer(${this.modelLayer.name}) has been removed`);
terrainview.removeLayer(this.modelLayer);
}
goto() {
this.cameraModel.goto();
}
}
class Room {
constructor(name, id) {
if (!name || !id) {
throw new Error("Both name and id are required");
}
this.name = name;
this.id = id;
this.guests = new Map();
this.roomUrl = `${window.location.href}?room=${this.id}`;
}
addGuest(guest) {
this.guests.set(guest.name, guest);
}
removeGuest(name) {
this.guests.get(name).deleteModel();
this.guests.delete(name);
}
}
/** Utils */
/**
* 回傳根據給定的座標點往指定方向移動指定距離後的新座標點
* @param {ov.GeoPoint} point 給定的座標點
* @param {ov.GeoPoint} vector 指定的方向
* @param {Number} distance 移動的距離(公尺)
* @returns {ov.GeoPoint} 新的座標點
*/
function getTranslatedPointByVector(point, vector, distance) {
let newPoint = point.Clone();
window.GetEPSGEngine().Transfer(4326, 3857, newPoint);
newPoint.x += distance * vector.x;
newPoint.y += distance * vector.y;
newPoint.z += distance * vector.z;
window.GetEPSGEngine().Transfer(3857, 4326, newPoint);
return newPoint;
}
/**
* 計算兩點之間的地理距離。
* @param {Object} p1 - 第一個點的座標 {x, y, z}。
* @param {Object} p2 - 第二個點的座標 {x, y, z}。
* @param {number} [epsg=4326] (option) - 座標系統的 EPSG 編碼。預設為 4326,即 WGS84 座標系統。
* @returns {number} 兩點之間的地理距離,單位為公尺。
*/
function calcPointGeoDistance(p1, p2, epsg = 4326) {
const { x: lon1, y: lat1, z: alt1 } = p1;
const { x: lon2, y: lat2, z: alt2 } = p2;
const phi1 = lat1 * (Math.PI / 180);
const phi2 = lat2 * (Math.PI / 180);
const lambda1 = lon1 * (Math.PI / 180);
const lambda2 = lon2 * (Math.PI / 180);
const R = 6371e3; // 地球半徑,單位為公尺
const theta = Math.acos(Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(lambda2 - lambda1));
const distance = R * theta;
const verticalDistance = Math.abs(alt1 - alt2);
const totalDistance = Math.sqrt(distance ** 2 + verticalDistance ** 2);
return totalDistance;
}
// 顯示 FPS
function showFPS() {
const fps = new ov.Widget.FPS({ view: terrainview, style: { "width": "100px", "height": "30px", "position": "absolute", "top": "1080px" } })
window.fps = fps
}
// 檢查字串是否為 JSON 格式
function isJSON(str) {
try {
return JSON.parse(str) && !!str;
} catch (e) {
return false;
}
}
// 提示框
async function saPrompt(title, defaultValue) {
const value = await Swal.fire({
title,
input: "text",
inputValue: defaultValue || "",
showCancelButton: true,
inputValidator: (value) => {
if (!value) {
return "無效的輸入";
}
},
});
if (value) {
return value;
}
}
/**
* 回傳一個隨機的名稱,格式範例 "{group}-{number}" ex: Alpha-1, Beta-2, Charlie-3
* @returns {string}
*/
function getRandomUserName() {
const group = ["Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliett", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
const groupName = group[Math.floor(Math.random() * group.length)];
const groupNumber = new Date().getTime().toString().substr(-3);
return `${groupName}-${groupNumber}`;
}
/**
* 提示視窗 (SweetAlert2)
* @param {*} title
* @param {*} icon "info" | "warning" | "error" | "success" | "question" ,預設為 "info"
* @param {*} text
*/
function saAlert(title, icon = "info", text) {
Swal.fire({
title,
text,
icon,
confirmButtonText: "OK",
});
}
/**
* 建立提示訊息實體, 詳見 https://sweetalert2.github.io/#toast
*/
const Toast = Swal.mixin({
toast: true,
position: "top-end",
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
didOpen: (toast) => {
toast.addEventListener("mouseenter", Swal.stopTimer);
toast.addEventListener("mouseleave", Swal.resumeTimer);
},
});
/**
* 處理滑鼠點擊事件
* @param {*} args 事件參數
*/
function handleMouseDown(...args) {
if (!createSymbolStatus) return; // 檢查圖標功能狀態
const ClickResultWorldPos = args[2]; // 取得滑鼠點擊的世界座標
if (args[0] == 0) { // 檢查是否為左鍵點擊
createSymbol(ClickResultWorldPos); // 建立圖標
createSymbolStatus = false; // 關閉圖標功能
}
}
/**
* 處理觸控結束事件
* @param {*} e 事件對象
*/
function handleTouchEnd(e) {
if (!createSymbolStatus) return;
const pageX = e.changedTouches[e.changedTouches.length - 1].pageX; // 取得點擊的頁面座標 x
const pageY = e.changedTouches[e.changedTouches.length - 1].pageY; // 取得點擊的頁面座標 y
let pos = new GeoPoint(pageX, pageY); // 使用頁面座標建立 GeoPoint 物件
terrainview.windowToTerrain(pos); // 將頁面座標轉換成世界座標
let geo3dPos = new Geo3DPoint(pos.x, pos.y, 0); // 建立 3D 的 GeoPoint 物件
createSymbol(geo3dPos); // 建立圖標
createSymbolStatus = false; // 關閉圖標功能
}
/**
* 處理鍵盤按鍵事件
* @param {*} e 事件對象
*/
function handleKeyUp(e) {
if (e.key === "c") {
var g = meetRoom.guests.get("November-287");
var scale = g.cameraModel._entity._Scale;
var gp = meetRoom.guests.get("November-287").cameraModel._entity._OriginalGeo;
var distance = calcPointGeoDistance(terrainview.camera.pos, gp);
var size = g.getCameraScreenSize();
console.log(`scale: ${scale}, distance: ${distance}, size: ${size}`);
}
if (e.key === "x") {
if (isMenuExist == false) { initUI() } else switchUIDisplay();
}
}
/**
* VR 初始化成功後的回呼函式
*/
function initVRSuccess() {
console.log("VR success!");
isVRMode = true;
userDevice = "vr";
if (ws && ws.readyState === 1 && meetRoom) sendMyUserDeviceType();
terrainview._TerrainEngine.XRHelper._MaxHeight = 200000;
terrainview.addXRGamepadEventListener({
handedness: "left",
buttonIndex: 4,
event: "onPressedDown",
callback: () => {
console.log("onPressedDown");
if (isMenuExist == false) { initUI() } else switchUIDisplay();
},
});
terrainview.addXRGamepadEventListener({
handedness: "right",
buttonIndex: 4,
event: "onPressedDown",
callback: () => {
console.log("on Righthand A Button PressedDown");
},
});
initUI();
}
CameraQuatenion.js
:
用來計算會議室中的其他訪客的頭盔及控制器模型的旋轉四元數。
[關於模型]
由於不同模型會有不同的座標系及正面朝向,建議使用相同的模型,以確保正確的旋轉。若想變更模型,請確保模型的正面朝向及原點與範例中的模型一致。提供模型下載連結如下:
const zUpMatrix = new Matrix4(); // 創建一個矩陣對象,用於將向量轉換為z軸向上的坐標系
zUpMatrix.elements[0] = -1; // 矩陣元素,用於將x軸反向
zUpMatrix.elements[1] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[2] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[3] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[4] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[5] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[6] = 1; // 矩陣元素,用於將z軸反向
zUpMatrix.elements[7] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[8] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[9] = -1; // 矩陣元素,用於將y軸反向
zUpMatrix.elements[10] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[11] = 0; // 矩陣元素,用於將坐標原點保持不變
zUpMatrix.elements[12] = 0; // 矩陣元素,用於將x軸保持不變
zUpMatrix.elements[13] = 0; // 矩陣元素,用於將y軸保持不變
zUpMatrix.elements[14] = 0; // 矩陣元素,用於將z軸保持不變
zUpMatrix.elements[15] = 1; // 矩陣元素,用於將坐標原點保持不變
/**
* 將向量轉換為四元數。
* @param {Object} v - 要轉換的向量,包含 x、y 和 z 屬性。
* @param {Object} up - 上方向向量,包含 x、y 和 z 屬性。
* @param {boolean} isHelmet - 是否為頭盔模式。
* @returns {Object} - 四元數物件,包含 x、y、z 和 w 屬性。
*/
const convertToQuaternion = (v, up, isHelmet) => {
const myMatrix = zUpMatrix; // 使用上面定義的矩陣對象
const adjustV = myMatrix.multiplyVector3(new Vector3([v.x, v.y, v.z])); // 將v向量應用矩陣變換
const adjustUp = myMatrix.multiplyVector3(new Vector3([up.x, up.y, up.z])); // 將up向量應用矩陣變換
const rotate = new Matrix4(); // 創建一個矩陣對象,用於旋轉
rotate.setLookAt(
0, 0, 0,
adjustV.elements[0], adjustV.elements[1], adjustV.elements[2],
-adjustUp.elements[0], -adjustUp.elements[1], -adjustUp.elements[2]
); // 設置旋轉矩陣的參數
const e = rotate.elements; // 獲取旋轉矩陣的元素
const [
m00, m01, m02, m03,
m10, m11, m12, m13,
m20, m21, m22, m23,
m30, m31, m32, m33
] = e; // 解構賦值,將旋轉矩陣的元素賦值給變量
let result;
const tr = m00 + m11 + m22;
if (tr > 0) {
const S = Math.sqrt(tr + 1.0) * 2; // S=4*qw
const qw = 0.25 * S;
const qx = (m21 - m12) / S;
const qy = (m02 - m20) / S;
const qz = (m10 - m01) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else if ((m00 > m11) & (m00 > m22)) {
const S = Math.sqrt(1.0 + m00 - m11 - m22) * 2; // S=4*qx
const qw = (m21 - m12) / S;
const qx = 0.25 * S;
const qy = (m01 + m10) / S;
const qz = (m02 + m20) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else if (m11 > m22) {
const S = Math.sqrt(1.0 + m11 - m00 - m22) * 2; // S=4*qy
const qw = (m02 - m20) / S;
const qx = (m01 + m10) / S;
const qy = 0.25 * S;
const qz = (m12 + m21) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else {
const S = Math.sqrt(1.0 + m22 - m00 - m11) * 2; // S=4*qz
const qw = (m10 - m01) / S;
const qx = (m02 + m20) / S;
const qy = (m12 + m21) / S;
const qz = 0.25 * S;
result = { x: qx, y: qy, z: qz, w: qw };
}
const angle = -(Math.PI / 2); // 設定90度並轉為弧度
const correctionQuaternion = {
w: Math.cos(angle / 2),
x: 0,
y: Math.sin(angle / 2),
z: 0
};
// 手把轉向 y 軸 180 度
const controllerAngle180 = Math.PI;
const controllerCorrectionQuaternion180 = {
w: Math.cos(controllerAngle180 / 2),
x: 0,
y: Math.sin(controllerAngle180 / 2),
z: 0
};
const resultQuaternion = multiplyQuaternions(result, isHelmet ? correctionQuaternion : controllerCorrectionQuaternion180);
if (isHelmet){
return resultQuaternion;
}else{
return resultQuaternion;
}
};
/**
* 將向量轉換為四元數的原點函數
* @param {Object} v - 要轉換的向量,包含 x、y、z 屬性
* @param {Object} up - 上方向向量,包含 x、y、z 屬性
* @returns {Object} - 四元數結果,包含 x、y、z、w 屬性
*/
const convertToQuaternionOrigin = (v, up) => {
const adjustV = myMatrix.multiplyVector3(new Vector3([v.x, v.y, v.z])); // 將v向量應用矩陣變換
const adjustUp = myMatrix.multiplyVector3(new Vector3([up.x, up.y, up.z])); // 將up向量應用矩陣變換
const rotate = new Matrix4(); // 創建一個矩陣對象,用於旋轉
rotate.setLookAt(
0, 0, 0,
adjustV.elements[0], adjustV.elements[1], adjustV.elements[2],
-adjustUp.elements[0], -adjustUp.elements[1], -adjustUp.elements[2]
); // 設置旋轉矩陣的參數
const e = rotate.elements; // 獲取旋轉矩陣的元素
const [
m00, m01, m02, m03,
m10, m11, m12, m13,
m20, m21, m22, m23,
m30, m31, m32, m33
] = e; // 解構賦值,將旋轉矩陣的元素賦值給變量
let result;
const tr = m00 + m11 + m22;
if (tr > 0) {
const S = Math.sqrt(tr + 1.0) * 2; // S=4*qw
const qw = 0.25 * S;
const qx = (m21 - m12) / S;
const qy = (m02 - m20) / S;
const qz = (m10 - m01) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else if ((m00 > m11) & (m00 > m22)) {
const S = Math.sqrt(1.0 + m00 - m11 - m22) * 2; // S=4*qx
const qw = (m21 - m12) / S;
const qx = 0.25 * S;
const qy = (m01 + m10) / S;
const qz = (m02 + m20) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else if (m11 > m22) {
const S = Math.sqrt(1.0 + m11 - m00 - m22) * 2; // S=4*qy
const qw = (m02 - m20) / S;
const qx = (m01 + m10) / S;
const qy = 0.25 * S;
const qz = (m12 + m21) / S;
result = { x: qx, y: qy, z: qz, w: qw };
} else {
const S = Math.sqrt(1.0 + m22 - m00 - m11) * 2; // S=4*qz
const qw = (m10 - m01) / S;
const qx = (m02 + m20) / S;
const qy = (m12 + m21) / S;
const qz = 0.25 * S;
result = { x: qx, y: qy, z: qz, w: qw };
}
return result;
};
/**
* 用於計算兩個四元數的乘法。
*
* @param {Object} q1 - 第一個四元數。
* @param {Object} q2 - 第二個四元數。
* @returns {Object} - 返回計算結果的四元數。
*/
function multiplyQuaternions(q1, q2) {
const a1 = q1.w; // 第一個四元數的w分量
const b1 = q1.x; // 第一個四元數的x分量
const c1 = q1.y; // 第一個四元數的y分量
const d1 = q1.z; // 第一個四元數的z分量
const a2 = q2.w; // 第二個四元數的w分量
const b2 = q2.x; // 第二個四元數的x分量
const c2 = q2.y; // 第二個四元數的y分量
const d2 = q2.z; // 第二個四元數的z分量
const w = a1 * a2 - b1 * b2 - c1 * c2 - d1 * d2; // 計算結果的w分量
const x = a1 * b2 + b1 * a2 + c1 * d2 - d1 * c2; // 計算結果的x分量
const y = a1 * c2 - b1 * d2 + c1 * a2 + d1 * b2; // 計算結果的y分量
const z = a1 * d2 + b1 * c2 - c1 * b2 + d1 * a2; // 計算結果的z分量
return { w: w, x: x, y: y, z: z }; // 返回計算結果的四元數
}
menuHandle.js
:
用來處理 VR 模式下的使用者選單。
// 全域變數
let modelset = null;
let isMenuExist = false;
let i3skh = null;
let acute3d = null;
let dddtilesTaipei = null;
// ICON 變數
const menuSvgSrc = new URL("./src/menu/menu_white_48dp.svg", location.href).href;
const accountSvgSrc = new URL("./src/menu/account_circle_white_48dp.svg", location.href).href;
const closeSvgSrc = new URL("./src/menu/close_white_48dp.svg", location.href).href;
const logoutSvgSrc = new URL("./src/menu/logout_white_48dp.svg", location.href).href;
const layersSvgSrc = new URL("./src/menu/layers_white_48dp.svg", location.href).href;
const placeSvgSrc = new URL("./src/menu/place_white_48dp.svg", location.href).href;
const addSvgSrc = new URL("./src/menu/add_circle_white_48dp.svg", location.href).href;
const visibleOnSvgSrc = new URL("./src/menu/visibility_white_48dp.svg", location.href).href;
const visibleOffSvgSrc = new URL("./src/menu/visibility_white_off_48dp.svg", location.href).href;
const deleteSvgSrc = new URL("./src/menu/delete_outline_white_48dp.svg", location.href).href;
// 基本距離
const baseDistance = 10;
// 使用者介面管理器
const menuManager = { buttons: [], layers: {} };
window.menuManager = menuManager;
/**
* 建立圖層選項
* @param {number} iconSize - 圖示尺寸
* @param {string} text - 圖層名稱
* @param {number} textWidth - 文字寬度
* @param {number} textHeight - 文字高度
* @param {()=>void} [callback] - 點擊回呼函式
* @returns {BlockEntity} - 圖層選項容器
*/
const createLayerContainer = (iconSize, text, textWidth, textHeight, callback) => {
menuManager.layers[text] = {};
// 圖層選項容器
const container = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("black"),
distance: baseDistance,
align: "start",
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
// onClick: callback
});
menuManager.layers[text].layerContainer = container;
// Icon Button - 前往
const btnGoto = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("gray"),
width: iconSize,
height: iconSize,
imgSrc: placeSvgSrc,
distance: baseDistance - 0.001,
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
onClick: () => {
const layer = getLayer(text);
if (layer) XRGoto(layer);
}
});
menuManager.layers[text].btnGoto = btnGoto;
// Icon Button - 載入
const btnLoad = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("gray"),
width: iconSize,
height: iconSize,
imgSrc: addSvgSrc,
distance: baseDistance - 0.001,
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
onClick: callback
});
menuManager.layers[text].btnLoad = btnLoad;
// Icon Button - 切換可見性
const btnSwitchVisible = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("gray"),
width: iconSize,
height: iconSize,
imgSrc: visibleOffSvgSrc,
distance: baseDistance - 0.001,
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
onClick: () => {
const layer = getLayer(text);
const currentVisible = layer.show;
if (currentVisible !== undefined) {
layer.show = !currentVisible;
if ('setDrawEdge' in layer) layer.setDrawEdge(!currentVisible);
}
btnSwitchVisible.update({ imgSrc: currentVisible ? visibleOffSvgSrc : visibleOnSvgSrc });
}
});
menuManager.layers[text].btnSwitchVisible = btnSwitchVisible;
// Icon Button - 移除
const btnRemove = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("gray"),
width: iconSize,
height: iconSize,
imgSrc: deleteSvgSrc,
distance: baseDistance - 0.001,
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
onClick: () => {
const layer = getLayer(text);
if (layer) terrainview.removeLayer(layer);
}
});
menuManager.layers[text].btnRemove = btnRemove;
// Icon Button - 移除
const gap = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("black"),
width: 0.1,
distance: baseDistance - 0.001,
});
menuManager.layers[text].gap = gap;
// Label - 圖層名稱
const lblLayerName = terrainview.createVRUITextEntity({
align: "left",
width: textWidth,
height: textHeight,
distance: baseDistance - 0.001,
onHoverDistance: baseDistance - 0.05,
onSelectDistance: baseDistance - 0.05,
label: {
text: ' ' + text + ' ',
fontSize: 50,
}
});
menuManager.layers[text].lblLayerName = lblLayerName;
container.addInline(gap);
container.addInline(lblLayerName);
container.addInline(btnLoad);
container.addInline(btnRemove);
container.addInline(btnSwitchVisible);
container.addInline(btnGoto);
return container;
};
/**
* 建立按鈕
* @param {string} icon - 圖示路徑
* @param {number} iconSize - 圖示尺寸
* @param {()=>void} [callback] - 點擊回呼函式
* @returns {Button} - 按鈕
*/
const createButton = (icon, iconSize, callback) => {
const button = terrainview.createVRUIBlockEntity({
color: new window.ov.Color("gray"),
onHoverColor: new window.ov.Color("5577ff"),
onSelectColor: new window.ov.Color("fff243"),
width: iconSize,
height: iconSize,
imgSrc: icon,
distance: baseDistance - 0.001,
onClick: callback
});
menuManager.buttons.push(button);
return button;
};
/**
* 切換 ModelSet 圖層的顯示
*/
const switchModelSet = () => {
if (!modelset) {
terrainview.addModelSetLayer({
url: "https://samplecode.pilotgaea.com.tw/src/PGWebJS/13.0/oviewRP.ashx",
identifier: "shp_Zhubei",
callback: function (success, layer) {
if (layer) {
modelset = layer;
modelset.setAllowHoverEntity(true);
modelset.setDrawEdge(true);
if (isVRMode) {
XRGoto(modelset);
} else {
modelset.goto();
}
} else {
console.log("gml載入不成功");
}
}
});
} else {
terrainview.removeLayer(modelset);
modelset = null;
}
};
/**
* 切換 i3s_Kaohsiung 圖層的顯示
*/
const switchI3SKH = () => {
if (!i3skh) {
terrainview.addOGCI3SLayer({
url: "https://i3s.nlsc.gov.tw/building/i3s/SceneServer/layers/4",
callback: (success, layer) => {
console.log(layer, success);
window.i3s = layer;
i3skh = layer;
if (isVRMode) {
XRGoto(i3skh);
} else {
i3skh.goto();
}
}
});
} else {
i3skh.show = !i3skh.show;
}
};
/**
* 切換 3DTilesTaipei 圖層的顯示
*/
const switch3DTilesTaipei = () => {
if (!i3skh) {
terrainview.addOGC3DTilesLayer({
url: "https://3dtiles.nlsc.gov.tw/building/tiles3d/0/tileset.json",
callback: (success, layer) => {
console.log(layer, success);
window.ddd = layer;
dddtilesTaipei = layer;
if (isVRMode) {
XRGoto(dddtilesTaipei);
} else {
dddtilesTaipei.goto();
}
}
});
} else {
i3skh.show = !i3skh.show;
}
};
/**
* 切換傾斜攝影圖層的顯示
*/
const switchAcute3d = () => {
if (!acute3d) {
terrainview.addPhotogrammetryModelLayer(
{
url: "https://samplecode.pilotgaea.com.tw/src/PGWebJS/13.0/oviewRP.ashx",
identifier: "範例傾斜攝影模型圖",
callback: function (success, layer) {
if (success) {
//設定變數給圖層後,goto()直接前往圖層位置
acute3d = layer;
if (isVRMode) {
XRGoto(acute3d);
} else {
acute3d.goto();
}
}
else {
console.log("傾斜攝影不成功");
}
}
}
);
} else {
acute3d.show = !acute3d.show;
}
}
/**
* 初始化使用者介面
*/
const initUI = () => {
isMenuExist = true;
const offsetX = 1.0;
const offsetY = -1.5;
const size = 0.5;
//小選單容器
const collapseMenuContainer = terrainview.createVRUIBlockEntity({
position: new window.GeoPoint(-2.5 + offsetX, 3 + offsetY),
color: new window.ov.Color("black"),
distance: baseDistance,
});
menuManager.collapseMenuContainer = collapseMenuContainer;
//大選單容器
const expandMenuContainer = terrainview.createVRUIBlockEntity({
position: new window.GeoPoint(-2.2 + offsetX, 3 + offsetY),
color: new window.ov.Color("black"),
distance: baseDistance,
});
expandMenuContainer.show = false;
menuManager.expandMenuContainer = expandMenuContainer;
//圖層容器
const layerContainer = terrainview.createVRUIBlockEntity({
position: new window.GeoPoint(-1.2, 1),
color: new window.ov.Color(0, 0, 0, 0),
distance: baseDistance + 0.001,
});
layerContainer.show = false;
window.lc = layerContainer;
console.log(lc);
menuManager.layerListContainer = layerContainer;
//#region 小選單
const menuButton = createButton(menuSvgSrc, size, () => {
collapseMenuContainer.show = false;
expandMenuContainer.show = true;
});
collapseMenuContainer.add(menuButton);
menuManager.menuButton = menuButton;
//#endregion
//#region 大選單
const closeButton = createButton(closeSvgSrc, size, () => {
collapseMenuContainer.show = true;
expandMenuContainer.show = false;
layerContainer.show = false;
});
const accountButton = createButton(accountSvgSrc, size);
const layerButton = createButton(layersSvgSrc, size, () => {
layerContainer.show = !layerContainer.show;
});
const logoutButton = createButton(logoutSvgSrc, size);
expandMenuContainer.add(closeButton);
// expandMenuContainer.addInline(accountButton);
expandMenuContainer.addInline(layerButton);
// expandMenuContainer.addInline(logoutButton);
//#endregion
menuManager.closeButton = closeButton;
menuManager.accountButton = accountButton;
menuManager.layerButton = layerButton;
menuManager.logoutButton = logoutButton;
//#region 圖層選單
layerContainer.add(createLayerContainer(size, "shp_Zhubei", 3, size, switchModelSet));
layerContainer.add(createLayerContainer(size, "i3s_Kaohsiung", 3, size, switchI3SKH));
layerContainer.add(createLayerContainer(size, "acute3d_TaiChung", 3, size, switchAcute3d));
layerContainer.add(createLayerContainer(size, "3DTiles_Taipei", 3, size, switch3DTilesTaipei));
//#endregion 圖層選單
//#region FPS
const fpsContainer = terrainview.createVRUIBlockEntity({
position: new window.GeoPoint(-1.75 + offsetX, 3.8 + offsetY),
color: new window.ov.Color("black"),
distance: baseDistance,
});
menuManager.fpsContainer = fpsContainer;
const fpsText = terrainview.createVRUITextEntity({
width: 2,
height: 0.5,
distance: baseDistance - 0.001,
label: {
text: `FPS: ${terrainview.getFPS()}`,
fontSize: 50,
}
});
window.fps = fpsContainer;
fpsContainer.add(fpsText);
menuManager.fpsText = fpsText;
setInterval(() => {
fpsText.update({
label: {
text: `FPS: ${terrainview.getFPS()}`,
}
});
}, 500);
//#endregion
terrainview.addVRUIBlockEntity(collapseMenuContainer);
terrainview.addVRUIBlockEntity(expandMenuContainer);
terrainview.addVRUIBlockEntity(layerContainer);
terrainview.addVRUIBlockEntity(fpsContainer);
};
/**
* 切換使用者介面的顯示
*/
const switchUIDisplay = () => {
terrainview._TerrainEngine.XRHelper.UIManager._Blocks.forEach(block => {
block.SetShow(!block.IsShow())
});
};
/**
* 前往指定圖層
* @param {Layer} layer - 圖層
*/
const XRGoto = (layer) => {
const geo3dSphere = layer._layer.GetBoundingSphere();
if (geo3dSphere) {
const pt = new Geo3DPoint(geo3dSphere.Center);
pt.z = (pt.z + (geo3dSphere.Radius / 2));
layer._TerrainView.setXRPosition(pt);
}
}
/**
* 取得指定名稱的圖層
* @param {string} layerName - 圖層名稱
* @returns {Layer} - 圖層
*/
const getLayer = (layerName) => {
const layers = {
"shp_Zhubei": modelset,
"i3s_Kaohsiung": i3skh,
"acute3d_TaiChung": acute3d,
"3DTiles_Taipei": dddtilesTaipei
}
return layers[layerName];
}
用來處理 VR 模式下的使用者選單。
style.css
:
用來設定網頁模式下的使用者選單的樣式。
html,
body,
#bodyContainer {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
background-color: #fff;
color: #000;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
line-height: 1.5em;
overflow: hidden;
}
#MapContainer {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
.EntityTooltip {
color: white !important;
text-shadow: red 0px 0px 6px !important;
transform: translateX(-65%);
}
#ToolContainer {
width: 120px;
position: absolute;
z-index: 10;
}
.btnFunc {
width: 100px;
height: 30px;
background-color: royalblue;
border: 1px solid white;
color: white;
border-radius: 5px;
cursor: pointer;
margin-left: 5px;
text-align: center;
margin-bottom: 2px;
}
div.btnFunc{
box-sizing: border-box;
display:flex;
justify-content:center;
align-items:center;
}
#userName {
border: 1px solid white;
border-radius: 5px;
margin: 5px;
padding: 5px;
color: white;
background-color: royalblue;
width: fit-content;
}
#userName.named {
border: 1px solid royalblue;
color: royalblue;
background-color: white;
width: fit-content;
}
#vr {
width: 100px;
height: 40px;
background-color: royalblue;
border: 1px solid white;
color: white;
border-radius: 5px;
cursor: pointer;
margin-left: 5px;
margin-bottom: 2px;
-webkit-user-select: none;
-moz-user-select: none;
-o-user-select: none;
user-select: none;
}
/* #vr attr: disable */
#vr[disabled] {
background-color: #ccc;
cursor: not-allowed;
}